From dd2fb35b0dacddfd23f164742a39c588b2d65c62 Mon Sep 17 00:00:00 2001 From: Daniel Milde Date: Sat, 21 Mar 2026 22:14:47 +0100 Subject: [PATCH] Import gdu_5.34.4.orig.tar.gz [dgit import orig gdu_5.34.4.orig.tar.gz] --- .github/ISSUE_TEMPLATE/bug_report.md | 32 + .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/dependabot.yml | 10 + .github/workflows/codeql-analysis.yml | 67 ++ .github/workflows/docker.yml | 42 + .github/workflows/test.yml | 64 + .github/workflows/winget.yml | 14 + .gitignore | 9 + .golangci.yml | 133 +++ .tito/packages/.readme | 3 + .tito/packages/gdu | 1 + .tito/tito.props | 5 + .tool-versions | 1 + Dockerfile | 15 + INSTALL.md | 146 +++ LICENSE.md | 8 + Makefile | 164 +++ README.md | 296 +++++ build/build.go | 14 + cmd/gdu/app/app.go | 591 +++++++++ cmd/gdu/app/app_linux_test.go | 108 ++ cmd/gdu/app/app_test.go | 612 ++++++++++ cmd/gdu/main.go | 262 ++++ cmd/gdu/main_test.go | 25 + codecov.yml | 10 + configuration.md | 197 +++ default.pgo | Bin 0 -> 62946 bytes docs/run-books.md | 13 + gdu.1 | 158 +++ gdu.1.md | 142 +++ gdu.png | Bin 0 -> 131657 bytes gdu.spec | 211 ++++ go.mod | 54 + go.sum | 170 +++ internal/common/analyze.go | 38 + internal/common/app.go | 23 + internal/common/const.go | 4 + internal/common/ignore.go | 235 ++++ internal/common/ignore_test.go | 470 ++++++++ internal/common/signal.go | 13 + internal/common/ui.go | 87 ++ internal/common/ui_test.go | 93 ++ internal/testanalyze/analyze.go | 114 ++ internal/testapp/app.go | 105 ++ internal/testdata/test.json | 7 + internal/testdata/wrong.json | 1 + internal/testdev/dev.go | 18 + internal/testdir/test_dir.go | 30 + pkg/analyze/dir_linux-openbsd.go | 44 + pkg/analyze/dir_linux_test.go | 64 + pkg/analyze/dir_other.go | 29 + pkg/analyze/dir_test.go | 354 ++++++ pkg/analyze/dir_unix.go | 44 + pkg/analyze/encode.go | 101 ++ pkg/analyze/encode_test.go | 68 ++ pkg/analyze/file.go | 324 +++++ pkg/analyze/file_test.go | 330 ++++++ pkg/analyze/parallel.go | 278 +++++ pkg/analyze/parallel_coverage_test.go | 152 +++ pkg/analyze/parallel_stable.go | 230 ++++ pkg/analyze/sequential.go | 236 ++++ pkg/analyze/sequential_coverage_test.go | 110 ++ pkg/analyze/sequential_test.go | 206 ++++ pkg/analyze/sort_test.go | 193 +++ pkg/analyze/sqlite.go | 883 ++++++++++++++ pkg/analyze/sqlite_modernc.go | 13 + pkg/analyze/sqlite_other.go | 10 + pkg/analyze/sqlite_test.go | 932 +++++++++++++++ pkg/analyze/storage.go | 150 +++ pkg/analyze/stored.go | 507 ++++++++ pkg/analyze/stored_coverage_test.go | 198 ++++ pkg/analyze/stored_test.go | 310 +++++ pkg/analyze/symlink.go | 40 + pkg/analyze/symlink_test.go | 42 + pkg/analyze/top.go | 48 + pkg/analyze/top_test.go | 69 ++ pkg/analyze/wait.go | 49 + pkg/analyze/zipdir.go | 196 +++ pkg/analyze/zipdir_coverage_test.go | 382 ++++++ pkg/analyze/zipdir_integration_test.go | 182 +++ pkg/analyze/zipdir_test.go | 174 +++ pkg/annex/annex.go | 65 + pkg/annex/annex_test.go | 39 + pkg/device/dev.go | 56 + pkg/device/dev_bsd.go | 72 ++ pkg/device/dev_bsd_test.go | 21 + pkg/device/dev_freebsd_darwin_other.go | 97 ++ pkg/device/dev_freebsd_darwin_test.go | 43 + pkg/device/dev_linux.go | 104 ++ pkg/device/dev_linux_test.go | 71 ++ pkg/device/dev_netbsd.go | 28 + pkg/device/dev_openbsd.go | 29 + pkg/device/dev_other.go | 21 + pkg/device/dev_test.go | 73 ++ pkg/fs/file.go | 177 +++ pkg/path/path.go | 26 + pkg/path/path_test.go | 15 + pkg/remove/parallel.go | 62 + pkg/remove/parallel_linux_test.go | 66 ++ pkg/remove/parallel_test.go | 69 ++ pkg/remove/remove.go | 39 + pkg/remove/remove_linux_test.go | 41 + pkg/remove/remove_test.go | 130 ++ pkg/timefilter/timefilter.go | 250 ++++ pkg/timefilter/timefilter_test.go | 767 ++++++++++++ report/export.go | 265 +++++ report/export_linux_test.go | 55 + report/export_test.go | 133 +++ report/import.go | 109 ++ report/import_test.go | 111 ++ snapcraft.yaml | 30 + stdout/stdout.go | 618 ++++++++++ stdout/stdout_linux_test.go | 27 + stdout/stdout_test.go | 573 +++++++++ tui/actions.go | 418 +++++++ tui/actions_linux_test.go | 24 + tui/actions_test.go | 512 ++++++++ tui/background.go | 99 ++ tui/collapse_minimal_test.go | 33 + tui/collapse_test.go | 303 +++++ tui/exec.go | 18 + tui/exec_other.go | 47 + tui/exec_test.go | 13 + tui/exec_windows.go | 33 + tui/export_test.go | 203 ++++ tui/filter.go | 142 +++ tui/filter_test.go | 480 ++++++++ tui/format.go | 279 +++++ tui/format_test.go | 175 +++ tui/keys.go | 449 +++++++ tui/keys_test.go | 1315 +++++++++++++++++++++ tui/marked.go | 148 +++ tui/marked_test.go | 22 + tui/mouse.go | 66 ++ tui/mouse_test.go | 162 +++ tui/progress.go | 53 + tui/show.go | 407 +++++++ tui/show_file.go | 151 +++ tui/show_file_test.go | 89 ++ tui/show_test.go | 82 ++ tui/sort.go | 94 ++ tui/sort_test.go | 207 ++++ tui/status.go | 84 ++ tui/tui.go | 585 +++++++++ tui/tui_test.go | 1019 ++++++++++++++++ tui/utils.go | 185 +++ tui/utils_test.go | 31 + 147 files changed, 23993 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/test.yml create mode 100644 .github/workflows/winget.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .tito/packages/.readme create mode 100644 .tito/packages/gdu create mode 100644 .tito/tito.props create mode 100644 .tool-versions create mode 100644 Dockerfile create mode 100644 INSTALL.md create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 build/build.go create mode 100644 cmd/gdu/app/app.go create mode 100644 cmd/gdu/app/app_linux_test.go create mode 100644 cmd/gdu/app/app_test.go create mode 100644 cmd/gdu/main.go create mode 100644 cmd/gdu/main_test.go create mode 100644 codecov.yml create mode 100644 configuration.md create mode 100644 default.pgo create mode 100644 docs/run-books.md create mode 100644 gdu.1 create mode 100644 gdu.1.md create mode 100644 gdu.png create mode 100644 gdu.spec create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/common/analyze.go create mode 100644 internal/common/app.go create mode 100644 internal/common/const.go create mode 100644 internal/common/ignore.go create mode 100644 internal/common/ignore_test.go create mode 100644 internal/common/signal.go create mode 100644 internal/common/ui.go create mode 100644 internal/common/ui_test.go create mode 100644 internal/testanalyze/analyze.go create mode 100644 internal/testapp/app.go create mode 100644 internal/testdata/test.json create mode 100644 internal/testdata/wrong.json create mode 100644 internal/testdev/dev.go create mode 100644 internal/testdir/test_dir.go create mode 100644 pkg/analyze/dir_linux-openbsd.go create mode 100644 pkg/analyze/dir_linux_test.go create mode 100644 pkg/analyze/dir_other.go create mode 100644 pkg/analyze/dir_test.go create mode 100644 pkg/analyze/dir_unix.go create mode 100644 pkg/analyze/encode.go create mode 100644 pkg/analyze/encode_test.go create mode 100644 pkg/analyze/file.go create mode 100644 pkg/analyze/file_test.go create mode 100644 pkg/analyze/parallel.go create mode 100644 pkg/analyze/parallel_coverage_test.go create mode 100644 pkg/analyze/parallel_stable.go create mode 100644 pkg/analyze/sequential.go create mode 100644 pkg/analyze/sequential_coverage_test.go create mode 100644 pkg/analyze/sequential_test.go create mode 100644 pkg/analyze/sort_test.go create mode 100644 pkg/analyze/sqlite.go create mode 100644 pkg/analyze/sqlite_modernc.go create mode 100644 pkg/analyze/sqlite_other.go create mode 100644 pkg/analyze/sqlite_test.go create mode 100644 pkg/analyze/storage.go create mode 100644 pkg/analyze/stored.go create mode 100644 pkg/analyze/stored_coverage_test.go create mode 100644 pkg/analyze/stored_test.go create mode 100644 pkg/analyze/symlink.go create mode 100644 pkg/analyze/symlink_test.go create mode 100644 pkg/analyze/top.go create mode 100644 pkg/analyze/top_test.go create mode 100644 pkg/analyze/wait.go create mode 100644 pkg/analyze/zipdir.go create mode 100644 pkg/analyze/zipdir_coverage_test.go create mode 100644 pkg/analyze/zipdir_integration_test.go create mode 100644 pkg/analyze/zipdir_test.go create mode 100644 pkg/annex/annex.go create mode 100644 pkg/annex/annex_test.go create mode 100644 pkg/device/dev.go create mode 100644 pkg/device/dev_bsd.go create mode 100644 pkg/device/dev_bsd_test.go create mode 100644 pkg/device/dev_freebsd_darwin_other.go create mode 100644 pkg/device/dev_freebsd_darwin_test.go create mode 100644 pkg/device/dev_linux.go create mode 100644 pkg/device/dev_linux_test.go create mode 100644 pkg/device/dev_netbsd.go create mode 100644 pkg/device/dev_openbsd.go create mode 100644 pkg/device/dev_other.go create mode 100644 pkg/device/dev_test.go create mode 100644 pkg/fs/file.go create mode 100644 pkg/path/path.go create mode 100644 pkg/path/path_test.go create mode 100644 pkg/remove/parallel.go create mode 100644 pkg/remove/parallel_linux_test.go create mode 100644 pkg/remove/parallel_test.go create mode 100644 pkg/remove/remove.go create mode 100644 pkg/remove/remove_linux_test.go create mode 100644 pkg/remove/remove_test.go create mode 100644 pkg/timefilter/timefilter.go create mode 100644 pkg/timefilter/timefilter_test.go create mode 100644 report/export.go create mode 100644 report/export_linux_test.go create mode 100644 report/export_test.go create mode 100644 report/import.go create mode 100644 report/import_test.go create mode 100644 snapcraft.yaml create mode 100644 stdout/stdout.go create mode 100644 stdout/stdout_linux_test.go create mode 100644 stdout/stdout_test.go create mode 100644 tui/actions.go create mode 100644 tui/actions_linux_test.go create mode 100644 tui/actions_test.go create mode 100644 tui/background.go create mode 100644 tui/collapse_minimal_test.go create mode 100644 tui/collapse_test.go create mode 100644 tui/exec.go create mode 100644 tui/exec_other.go create mode 100644 tui/exec_test.go create mode 100644 tui/exec_windows.go create mode 100644 tui/export_test.go create mode 100644 tui/filter.go create mode 100644 tui/filter_test.go create mode 100644 tui/format.go create mode 100644 tui/format_test.go create mode 100644 tui/keys.go create mode 100644 tui/keys_test.go create mode 100644 tui/marked.go create mode 100644 tui/marked_test.go create mode 100644 tui/mouse.go create mode 100644 tui/mouse_test.go create mode 100644 tui/progress.go create mode 100644 tui/show.go create mode 100644 tui/show_file.go create mode 100644 tui/show_file_test.go create mode 100644 tui/show_test.go create mode 100644 tui/sort.go create mode 100644 tui/sort_test.go create mode 100644 tui/status.go create mode 100644 tui/tui.go create mode 100644 tui/tui_test.go create mode 100644 tui/utils.go create mode 100644 tui/utils_test.go diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4c10062 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**System (please complete the following information):** + - OS: [e.g. ArchLinux] + - Terminal [e.g. xTerm, Guake] + - Version [e.g. 5.22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..615dfde --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..6da69cb --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,67 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '21 0 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'go' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..04d972b --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,42 @@ +name: Docker + +on: + workflow_dispatch: + push: + branches: + - 'master' + tags: + - 'v*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v6 + - + name: Login to registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - + name: Docker meta + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - + name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d47d6f9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,64 @@ +on: + push: + branches: + - master + pull_request: + branches: + - master + +name: run tests +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: actions/setup-go@v6 + with: + go-version: 1.25.x + - name: Checkout code + uses: actions/checkout@v6 + - name: Run linters + uses: golangci/golangci-lint-action@v9 + with: + version: v2.6 + + test: + strategy: + matrix: + go-version: [1.24.x, 1.25.x] + platform: [ubuntu-latest] + include: + - go-version: 1.25.x + platform: macos-latest + runs-on: ${{ matrix.platform }} + steps: + - name: Install Go + if: success() + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v6 + - name: Run tests + run: go test -v -covermode=count ./... + + coverage: + runs-on: ubuntu-latest + steps: + - name: Install Go + if: success() + uses: actions/setup-go@v6 + with: + go-version: 1.25.x + - name: Checkout code + uses: actions/checkout@v6 + - name: Calc coverage + run: | + go test -v -race -covermode=atomic -coverprofile=coverage.out ./... + - name: Upload coverage report + uses: codecov/codecov-action@v5 + with: + files: ./coverage.out + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml new file mode 100644 index 0000000..33902c8 --- /dev/null +++ b/.github/workflows/winget.yml @@ -0,0 +1,14 @@ +name: Publish to Winget +on: + release: + types: [released] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: vedantmgoyal2009/winget-releaser@v2 + with: + identifier: dundee.gdu + installers-regex: '_windows_[\w.]+\.zip$' + token: ${{ secrets.WINGET_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3250011 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.vscode +/.idea +/coverage.txt +/coverage.out +/coverage.html +/dist +/test_dir +/tui/test_dir +/vendor diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..f8b03a5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,133 @@ +version: "2" +output: + formats: + text: + path: stdout +linters: + default: none + enable: + - bodyclose + - copyloopvar + - dogsled + - errcheck + - errorlint + - exhaustive + - funlen + - goconst + - gocritic + - gocyclo + - govet + - ineffassign + - lll + - nakedret + - revive + - staticcheck + - unparam + - unused + - whitespace + settings: + dupl: + threshold: 100 + errcheck: + check-blank: true + funlen: + lines: 500 + statements: 50 + goconst: + min-len: 3 + min-occurrences: 3 + gocritic: + disabled-checks: + - whyNoLint + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + gocyclo: + min-complexity: 25 + govet: + enable: + - shadow + lll: + line-length: 160 + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + - name: redefines-builtin-id + # While we agree with this rule, right now it would break too many + # projects. So, we disable it by default. + - name: unused-parameter + disabled: true + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - errcheck + - funlen + - gochecknoglobals # Globals in test files are tolerated. + - goconst # Repeated consts in test files are tolerated. + - gocritic + - gocyclo + - gosec + path: _test\.go + # This rule is buggy and breaks on our `///Block` lines. Disable for now. + - linters: + - gocritic + text: 'commentFormatting: put a space' + # This rule incorrectly flags nil references after assert.Assert(t, x != nil) + - linters: + - staticcheck + path: _test\.go + text: SA5011 + - linters: + - lll + source: '^//go:generate ' + - linters: + - gocritic + - lll + path: \.resolvers\.go + source: '^func \(r \*[a-zA-Z]+Resolvers\) ' + - path: (.+)\.go$ + # We allow error shadowing + text: declaration of "err" shadows declaration at + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.tito/packages/.readme b/.tito/packages/.readme new file mode 100644 index 0000000..b9411e2 --- /dev/null +++ b/.tito/packages/.readme @@ -0,0 +1,3 @@ +the .tito/packages directory contains metadata files +named after their packages. Each file has the latest tagged +version and the project's relative directory. diff --git a/.tito/packages/gdu b/.tito/packages/gdu new file mode 100644 index 0000000..497b3fd --- /dev/null +++ b/.tito/packages/gdu @@ -0,0 +1 @@ +5.25.0-1 ./ diff --git a/.tito/tito.props b/.tito/tito.props new file mode 100644 index 0000000..eab3f19 --- /dev/null +++ b/.tito/tito.props @@ -0,0 +1,5 @@ +[buildconfig] +builder = tito.builder.Builder +tagger = tito.tagger.VersionTagger +changelog_do_not_remove_cherrypick = 0 +changelog_format = %s (%ae) diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..309ff5c --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.25.5 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ee9a60 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM docker.io/library/golang:1.25.5 as builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN make build-static + +FROM scratch + +COPY --from=builder /app/dist/gdu /opt/gdu + +ENTRYPOINT ["/opt/gdu"] diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..9f64964 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,146 @@ +# Installation + +[Arch Linux](https://archlinux.org/packages/extra/x86_64/gdu/): + + pacman -S gdu + +[Debian](https://packages.debian.org/bullseye/gdu): + + apt install gdu + +[Ubuntu](https://launchpad.net/~daniel-milde/+archive/ubuntu/gdu) + + add-apt-repository ppa:daniel-milde/gdu + apt-get update + apt-get install gdu + +[NixOS](https://search.nixos.org/packages?channel=unstable&show=gdu&query=gdu): + + nix-env -iA nixos.gdu + +[Homebrew](https://formulae.brew.sh/formula/gdu): + + brew install -f gdu + # gdu will be installed as `gdu-go` to avoid conflicts with coreutils + gdu-go + +[Mise](https://github.com/jdx/mise): + + mise use -g gdu@latest + +[Snap](https://snapcraft.io/gdu-disk-usage-analyzer): + + snap install gdu-disk-usage-analyzer + snap connect gdu-disk-usage-analyzer:mount-observe :mount-observe + snap connect gdu-disk-usage-analyzer:system-backup :system-backup + snap alias gdu-disk-usage-analyzer.gdu gdu + +[Binenv](https://github.com/devops-works/binenv) + + binenv install gdu + +[Go](https://pkg.go.dev/github.com/dundee/gdu): + + go install github.com/dundee/gdu/v5/cmd/gdu@latest + +[Winget](https://github.com/microsoft/winget-pkgs/tree/master/manifests/d/dundee/gdu) (for Windows users): + + winget install gdu + +You can either run it as `gdu_windows_amd64.exe` or +* add an alias with `Doskey`. +* add `alias gdu="gdu_windows_amd64.exe"` to your `~/.bashrc` file if using Git Bash to run it as `gdu`. + +You might need to restart your terminal. + +[Scoop](https://github.com/ScoopInstaller/Main/blob/master/bucket/gdu.json): + + scoop install gdu + +[X-cmd](https://www.x-cmd.com/start/) + + x env use gdu + +## [COPR builds](https://copr.fedorainfracloud.org/coprs/faramirza/gdu/) +COPR Builds exist for the the following Linux Distros. + +[How to enable a CORP Repo](https://docs.pagure.org/copr.copr/how_to_enable_repo.html) + +Amazon Linux 2023: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/amazonlinux-2023-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +EPEL 7: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-7-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +EPEL 8: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-8-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +EPEL 9: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-9-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +Fedora 38: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/fedora-$releasever-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +Fedora 39: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/fedora-$releasever-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..3d3b99f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,8 @@ +Copyright 2020-2021 Daniel Milde + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7d7fd83 --- /dev/null +++ b/Makefile @@ -0,0 +1,164 @@ +NAME := gdu +MAJOR_VER := v5 +PACKAGE := github.com/dundee/$(NAME)/$(MAJOR_VER) +CMD_GDU := cmd/gdu +VERSION := $(shell git describe --tags 2>/dev/null) +NAMEVER := $(NAME)-$(subst v,,$(VERSION)) +DATE := $(shell date +'%Y-%m-%d') +GOBIN := go +GOFLAGS ?= -buildmode=pie -trimpath -mod=readonly -modcacherw -pgo=default.pgo +GOFLAGS_STATIC ?= -trimpath -mod=readonly -modcacherw -pgo=default.pgo +LDFLAGS := -s -w -extldflags '-static' \ + -X '$(PACKAGE)/build.Version=$(VERSION)' \ + -X '$(PACKAGE)/build.User=$(shell id -u -n)' \ + -X '$(PACKAGE)/build.Time=$(shell LC_ALL=en_US.UTF-8 date)' +TAR := tar +ifeq ($(shell uname -s),Darwin) + TAR := gtar # brew install gnu-tar +endif + +all: clean tarball build-all build-docker man clean-uncompressed-dist shasums + +run: + go run $(PACKAGE)/$(CMD_GDU) + +vendor: go.mod go.sum + go mod vendor + +tarball: vendor + -mkdir dist + $(TAR) czf dist/$(NAMEVER).tgz --transform "s,^,$(NAMEVER)/," --exclude dist --exclude test_dir --exclude coverage.txt * + +build: + @echo "Version: " $(VERSION) + mkdir -p dist + GOFLAGS="$(GOFLAGS)" CGO_ENABLED=0 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/$(NAME) $(PACKAGE)/$(CMD_GDU) + +build-static: + @echo "Version: " $(VERSION) + mkdir -p dist + GOFLAGS="$(GOFLAGS_STATIC)" CGO_ENABLED=0 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/$(NAME) $(PACKAGE)/$(CMD_GDU) + +build-docker: + @echo "Version: " $(VERSION) + docker build . --tag ghcr.io/dundee/gdu:$(VERSION) + +build-all: + @echo "Version: " $(VERSION) + -mkdir dist + -CGO_ENABLED=0 gox \ + -os="darwin" \ + -arch="amd64 arm64" \ + -output="dist/gdu_{{.OS}}_{{.Arch}}" \ + -ldflags="$(LDFLAGS)" \ + $(PACKAGE)/$(CMD_GDU) + + -CGO_ENABLED=0 gox \ + -os="windows" \ + -arch="amd64" \ + -output="dist/gdu_{{.OS}}_{{.Arch}}" \ + -ldflags="$(LDFLAGS)" \ + $(PACKAGE)/$(CMD_GDU) + + -CGO_ENABLED=0 gox \ + -os="linux freebsd netbsd openbsd" \ + -output="dist/gdu_{{.OS}}_{{.Arch}}" \ + -ldflags="$(LDFLAGS)" \ + $(PACKAGE)/$(CMD_GDU) + + GOFLAGS="$(GOFLAGS)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_amd64 $(PACKAGE)/$(CMD_GDU) + GOFLAGS="$(GOFLAGS_STATIC)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_amd64_static $(PACKAGE)/$(CMD_GDU) + + CGO_ENABLED=0 GOOS=linux GOARM=5 GOARCH=arm $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv5l $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=linux GOARM=6 GOARCH=arm $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv6l $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=linux GOARM=7 GOARCH=arm $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv7l $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_arm64 $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=android GOARCH=arm64 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_android_arm64 $(PACKAGE)/$(CMD_GDU) + + cd dist; for file in gdu_linux_* gdu_darwin_* gdu_netbsd_* gdu_openbsd_* gdu_freebsd_* gdu_android_*; do tar czf $$file.tgz $$file; done + cd dist; for file in gdu_windows_*; do zip $$file.zip $$file; done + +gdu.1: gdu.1.md + sed 's/{{date}}/$(DATE)/g' gdu.1.md > gdu.1.date.md + pandoc gdu.1.date.md -s -t man > gdu.1 + rm -f gdu.1.date.md + +man: gdu.1 + cp gdu.1 dist + cd dist; tar czf gdu.1.tgz gdu.1 + +show-man: + sed 's/{{date}}/$(DATE)/g' gdu.1.md > gdu.1.date.md + pandoc gdu.1.date.md -s -t man | man -l - + +test: + gotestsum + +coverage: + gotestsum -- -race -coverprofile=coverage.txt -covermode=atomic ./... + +coverage-html: coverage + $(GOBIN) tool cover -html=coverage.txt + +gobench: + $(GOBIN) test -bench=. $(PACKAGE)/pkg/analyze + +heap-profile: + $(GOBIN) tool pprof -web http://localhost:6060/debug/pprof/heap + +pgo: + wget -O cpu.pprof http://localhost:6060/debug/pprof/profile?seconds=30 + $(GOBIN) tool pprof -proto cpu.pprof default.pgo > merged.pprof + mv merged.pprof default.pgo + +trace: + wget -O trace.out http://localhost:6060/debug/pprof/trace?seconds=30 + gotraceui ./trace.out + +profile: + wget -O cpu.pprof http://localhost:6060/debug/pprof/profile?seconds=30 + $(GOBIN) tool pprof -web cpu.pprof + +benchmark: + sudo cpupower frequency-set -g performance + hyperfine --export-markdown=bench-cold.md \ + --prepare 'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches' \ + --ignore-failure \ + 'dua ~' 'duc index ~' 'ncdu -0 -o /dev/null ~' \ + 'diskus ~' 'du -hs ~' 'dust -d0 ~' 'pdu ~' \ + 'gdu -npc ~' 'gdu -gnpc ~' 'gdu -npc --use-storage ~' + hyperfine --export-markdown=bench-warm.md \ + --warmup 5 \ + --ignore-failure \ + 'dua ~' 'duc index ~' 'ncdu -0 -o /dev/null ~' \ + 'diskus ~' 'du -hs ~' 'dust -d0 ~' 'pdu ~' \ + 'gdu -npc ~' 'gdu -gnpc ~' 'gdu -npc --use-storage ~' + sudo cpupower frequency-set -g schedutil + +lint: + golangci-lint run -c .golangci.yml + +clean: + $(GOBIN) mod tidy + -rm coverage.txt + -rm -r test_dir + -rm -r vendor + -rm -r dist + +clean-uncompressed-dist: + find dist -type f -not -name '*.tgz' -not -name '*.zip' -delete + +shasums: + cd dist; sha256sum * > sha256sums.txt + cd dist; gpg --sign --armor --detach-sign sha256sums.txt + +release: + gh release create -t "gdu $(VERSION)" $(VERSION) ./dist/* + +install-dev-dependencies: + $(GOBIN) install gotest.tools/gotestsum@latest + $(GOBIN) install github.com/mitchellh/gox@latest + $(GOBIN) install honnef.co/go/gotraceui/cmd/gotraceui@latest + $(GOBIN) install github.com/golangci/golangci-lint/cmd/golangci-lint@2.8.0 + +.PHONY: run build build-static build-all test gobench benchmark coverage coverage-html clean clean-uncompressed-dist man show-man release dev-build diff --git a/README.md b/README.md new file mode 100644 index 0000000..a504b7b --- /dev/null +++ b/README.md @@ -0,0 +1,296 @@ +# go DiskUsage() + +Gdu + +[![Codecov](https://codecov.io/gh/dundee/gdu/branch/master/graph/badge.svg)](https://codecov.io/gh/dundee/gdu) +[![Go Report Card](https://goreportcard.com/badge/github.com/dundee/gdu)](https://goreportcard.com/report/github.com/dundee/gdu) +[![Maintainability](https://api.codeclimate.com/v1/badges/30d793274607f599e658/maintainability)](https://codeclimate.com/github/dundee/gdu/maintainability) +[![CodeScene Code Health](https://codescene.io/projects/13129/status-badges/code-health)](https://codescene.io/projects/13129) + +Pretty fast disk usage analyzer written in Go. + +Gdu is intended primarily for SSD disks where it can fully utilize parallel processing. +However HDDs work as well, but the performance gain is not so huge. + +[![asciicast](https://asciinema.org/a/382738.svg)](https://asciinema.org/a/382738) + + + Packaging status + + +## Installation + +Head for the [releases page](https://github.com/dundee/gdu/releases) and download the binary for your system. + +Using curl: + + curl -L https://github.com/dundee/gdu/releases/latest/download/gdu_linux_amd64.tgz | tar xz + chmod +x gdu_linux_amd64 + mv gdu_linux_amd64 /usr/bin/gdu + +See the [installation page](./INSTALL.md) for other ways how to install Gdu to your system. + +Or you can use Gdu directly via Docker: + + docker run --rm --init --interactive --tty --privileged --volume /:/mnt/root ghcr.io/dundee/gdu /mnt/root + +## Usage + +``` + gdu [directory_to_scan] [flags] + +Flags: + --archive-browsing Enable browsing of zip/jar archives + --collapse-path Collapse single-child directory chains + --config-file string Read config from file (default is $HOME/.gdu.yaml) + -D, --db string Store analysis in database (*.sqlite for SQLite, *.badger for BadgerDB) + --depth int Show directory structure up to specified depth in non-interactive mode (0 means the flag is ignored) + --enable-profiling Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ + -E, --exclude-type strings File types to exclude (e.g., --exclude-type yaml,json) + -L, --follow-symlinks Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed) + -h, --help help for gdu + -i, --ignore-dirs strings Paths to ignore (separated by comma). Can be absolute or relative to current directory (default [/proc,/dev,/sys,/run]) + -I, --ignore-dirs-pattern strings Path patterns to ignore (separated by comma) + -X, --ignore-from string Read path patterns to ignore from file + -f, --input-file string Import analysis from JSON file + -l, --log-file string Path to a logfile (default "/dev/null") + --max-age string Include files with mtime no older than DURATION (e.g., 7d, 2h30m, 1y2mo) + -m, --max-cores int Set max cores that Gdu will use. 8 cores available (default 8) + --min-age string Include files with mtime at least DURATION old (e.g., 30d, 1w) + --mouse Use mouse + -c, --no-color Do not use colorized output + -x, --no-cross Do not cross filesystem boundaries + --no-delete Do not allow deletions + --no-view-file Do not allow viewing file contents + -H, --no-hidden Ignore hidden directories (beginning with dot) + --no-prefix Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode + -p, --no-progress Do not show progress in non-interactive mode + --no-spawn-shell Do not allow spawning shell + -u, --no-unicode Do not use Unicode symbols (for size bar) + -n, --non-interactive Do not run in interactive mode + -o, --output-file string Export all info into file as JSON + -r, --read-from-storage Use existing database instead of re-scanning + --reverse-sort Reverse sorting order (smallest to largest) in non-interactive mode + --sequential Use sequential scanning (intended for rotating HDDs) + -A, --show-annexed-size Use apparent size of git-annex'ed files in case files are not present locally (real usage is zero) + -a, --show-apparent-size Show apparent size + -d, --show-disks Show all mounted disks + -k, --show-in-kib Show sizes in KiB (or kB with --si) in non-interactive mode + -C, --show-item-count Show number of items in directory + -M, --show-mtime Show latest mtime of items in directory + -B, --show-relative-size Show relative size + --si Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) + --since string Include files with mtime >= WHEN. WHEN accepts RFC3339 timestamp (e.g., 2025-08-11T01:00:00-07:00) or date only YYYY-MM-DD (calendar-day compare; includes the whole day) + -s, --summarize Show only a total in non-interactive mode + -t, --top int Show only top X largest files in non-interactive mode + -T, --type strings File types to include (e.g., --type yaml,json) + --until string Include files with mtime <= WHEN. WHEN accepts RFC3339 timestamp or date only YYYY-MM-DD + -v, --version Print version + --write-config Write current configuration to file (default is $HOME/.gdu.yaml) + +Basic list of actions in interactive mode (show help modal for more): + ↑ or k Move cursor up + ↓ or j Move cursor down + → or Enter or l Go to highlighted directory + ← or h Go to parent directory + d Delete the selected file or directory + e Empty the selected directory + n Sort by name + s Sort by size + c Show number of items in directory + ? Show help modal +``` + +## Examples + + gdu # analyze current dir + gdu -a # show apparent size instead of disk usage + gdu --no-delete # prevent write operations + gdu --no-view-file # prevent viewing file contents + gdu # analyze given dir + gdu -d # show all mounted disks + gdu -l ./gdu.log # write errors to log file + gdu -i /sys,/proc / # ignore some paths + gdu -I '.*[abc]+' # ignore paths by regular pattern + gdu -X ignore_file / # ignore paths by regular patterns from file + gdu -c / # use only white/gray/black colors + + gdu -n / # only print stats, do not start interactive mode + gdu -p / # do not show progress, useful when using its output in a script + gdu -ps /some/dir # show only total usage for given dir + gdu -t 10 / # show top 10 largest files + gdu --reverse-sort -n / # show files sorted from smallest to largest in non-interactive mode + gdu / > file # write stats to file, do not start interactive mode + + gdu -o- / | gzip -c >report.json.gz # write all info to JSON file for later analysis + zcat report.json.gz | gdu -f- # read analysis from file + + GOGC=10 gdu -g --use-storage / # use persistent key-value storage for saving analysis data + gdu -r / # read saved analysis data from persistent key-value storage + +## Modes + +Gdu has three modes: interactive (default), non-interactive and export. + +Non-interactive mode is started automatically when TTY is not detected (using [go-isatty](https://github.com/mattn/go-isatty)), for example if the output is being piped to a file, or it can be started explicitly by using a flag. + +Export mode (flag `-o`) outputs all usage data as JSON, which can be later opened using the `-f` flag. + +Hard links are counted only once. + +## File flags + +Files and directories may be prefixed by a one-character +flag with following meaning: + +* `!` An error occurred while reading this directory. + +* `.` An error occurred while reading a subdirectory, size may be not correct. + +* `@` File is symlink or socket. + +* `H` Same file was already counted (hard link). + +* `e` Directory is empty. + +## Configuration file + +Gdu can read (and write) YAML configuration file. + +`$HOME/.config/gdu/gdu.yaml` and `$HOME/.gdu.yaml` are checked for the presence of the config file by default. + +See the [full list of all configuration options](configuration.md). + +### Examples + +* To configure gdu to permanently run in gray-scale color mode: + +``` +echo "no-color: true" >> ~/.gdu.yaml +``` + +* To set default sorting in configuration file: + +``` +sorting: + by: name // size, name, itemCount, mtime + order: desc +``` + +* To configure gdu to set CWD variable when browsing directories: + +``` +echo "change-cwd: true" >> ~/.gdu.yaml +``` + +* To save the current configuration + +``` +gdu --write-config +``` + +## Styling + +There are wide options for how terminals can be colored. +Some gdu primitives (like basic text) adapt to different color schemas, but the selected/highlighted row does not. + +If the default look is not sufficient, it can be changed in configuration file, e.g.: + +``` +style: + selected-row: + text-color: black + background-color: "#ff0000" +``` + +## Deletion in background and in parallel (experimental) + +Gdu can delete items in the background, thus not blocking the UI for additional work. +To enable: + +``` +echo "delete-in-background: true" >> ~/.gdu.yaml +``` + +Directory items can be also deleted in parallel, which might increase the speed of deletion. +To enable: + +``` +echo "delete-in-parallel: true" >> ~/.gdu.yaml +``` + +## Saving analysis data to database + +Gdu can store the analysis data to a database file instead of just memory. +This allows you to save and reload analysis results later. +Both SQLite and BadgerDB are supported. + +``` +gdu --db analysis.sqlite / # saves analysis data to SQLite database +gdu --db analysis.badger / # saves analysis data to BadgerDB +gdu -r --db analysis.sqlite / # reads saved data, does not run analysis again +``` + +## Running tests + + make install-dev-dependencies + make test + +## Profiling + +Gdu can collect profiling data when the `--enable-profiling` flag is set. +The data are provided via embedded http server on URL `http://localhost:6060/debug/pprof/`. + +You can then use e.g. `go tool pprof -web http://localhost:6060/debug/pprof/heap` +to open the heap profile as SVG image in your web browser. + +## Benchmarks + +Benchmarks were performed on 50G directory (100k directories, 400k files) on 500 GB SSD using [hyperfine](https://github.com/sharkdp/hyperfine). +See `benchmark` target in [Makefile](Makefile) for more info. + +### Cold cache + +Filesystem cache was cleared using `sync; echo 3 | sudo tee /proc/sys/vm/drop_caches`. + +| Command | Mean [s] | Min [s] | Max [s] | Relative | +|:---|---:|---:|---:|---:| +| `diskus ~` | 3.074 ± 0.010 | 3.056 | 3.094 | 1.00 | +| `gdu -npc ~` | 3.133 ± 0.013 | 3.116 | 3.159 | 1.02 ± 0.01 | +| `gdu -gnpc ~` | 3.157 ± 0.013 | 3.139 | 3.180 | 1.03 ± 0.01 | +| `pdu ~` | 3.772 ± 0.149 | 3.630 | 4.071 | 1.23 ± 0.05 | +| `dust -d0 ~` | 4.001 ± 0.162 | 3.786 | 4.305 | 1.30 ± 0.05 | +| `dua ~` | 5.315 ± 3.210 | 4.068 | 14.447 | 1.73 ± 1.04 | +| `gdu -npc --use-storage ~` | 12.690 ± 0.527 | 11.325 | 13.091 | 4.13 ± 0.17 | +| `du -hs ~` | 14.940 ± 0.064 | 14.852 | 15.048 | 4.86 ± 0.03 | +| `duc index ~` | 15.501 ± 0.136 | 15.386 | 15.849 | 5.04 ± 0.05 | +| `ncdu -0 -o /dev/null ~` | 15.688 ± 0.053 | 15.610 | 15.789 | 5.10 ± 0.02 | + +### Warm cache + +| Command | Mean [ms] | Min [ms] | Max [ms] | Relative | +|:---|---:|---:|---:|---:| +| `diskus ~` | 211.4 ± 3.7 | 206.4 | 219.3 | 1.00 | +| `pdu ~` | 221.8 ± 2.4 | 219.3 | 226.3 | 1.05 ± 0.02 | +| `dust -d0 ~` | 363.6 ± 5.4 | 357.3 | 373.2 | 1.72 ± 0.04 | +| `gdu -npc ~` | 434.3 ± 3.4 | 426.0 | 437.8 | 2.05 ± 0.04 | +| `dua ~` | 451.2 ± 4.2 | 444.9 | 457.9 | 2.13 ± 0.04 | +| `gdu -gnpc ~` | 521.0 ± 14.0 | 510.9 | 558.5 | 2.46 ± 0.08 | +| `du -hs ~` | 809.4 ± 3.2 | 804.8 | 816.0 | 3.83 ± 0.07 | +| `duc index ~` | 952.3 ± 4.8 | 946.0 | 961.7 | 4.50 ± 0.08 | +| `ncdu -0 -o /dev/null ~` | 1432.8 ± 3.4 | 1428.0 | 1439.0 | 6.78 ± 0.12 | +| `gdu -npc --use-storage ~` | 9950.0 ± 474.1 | 9117.5 | 10647.4 | 47.07 ± 2.39 | + +## Alternatives + +* [ncdu](https://dev.yorhel.nl/ncdu) - NCurses based tool written in pure `C` (LTS) or `zig` (Stable) +* [godu](https://github.com/viktomas/godu) - Analyzer with a carousel like user interface +* [dua](https://github.com/Byron/dua-cli) - Tool written in `Rust` with interface similar to gdu (and ncdu) +* [diskus](https://github.com/sharkdp/diskus) - Very simple but very fast tool written in `Rust` +* [duc](https://duc.zevv.nl/) - Collection of tools with many possibilities for inspecting and visualising disk usage +* [dust](https://github.com/bootandy/dust) - Tool written in `Rust` showing tree like structures of disk usage +* [pdu](https://github.com/KSXGitHub/parallel-disk-usage) - Tool written in `Rust` showing tree like structures of disk usage + +## Notes + +[HDD icon created by Nikita Golubev - Flaticon](https://www.flaticon.com/free-icons/hdd) diff --git a/build/build.go b/build/build.go new file mode 100644 index 0000000..c5fa6cd --- /dev/null +++ b/build/build.go @@ -0,0 +1,14 @@ +package build + +// Version stores the current version of the app +var Version = "development" + +// Time of the build +var Time string + +// User who built it +var User string + +// RootPathPrefix stores path to be prepended to given absolute path +// e.g. /var/lib/snapd/hostfs for snap +var RootPathPrefix = "" diff --git a/cmd/gdu/app/app.go b/cmd/gdu/app/app.go new file mode 100644 index 0000000..8760a97 --- /dev/null +++ b/cmd/gdu/app/app.go @@ -0,0 +1,591 @@ +package app + +import ( + "fmt" + "io" + "io/fs" + "net/http" + "net/http/pprof" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/build" + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/device" + gfs "github.com/dundee/gdu/v5/pkg/fs" + "github.com/dundee/gdu/v5/pkg/timefilter" + "github.com/dundee/gdu/v5/report" + "github.com/dundee/gdu/v5/stdout" + "github.com/dundee/gdu/v5/tui" +) + +// UI is common interface for both terminal UI and text output +type UI interface { + ListDevices(getter device.DevicesInfoGetter) error + AnalyzePath(path string, parentDir gfs.Item) error + ReadAnalysis(input io.Reader) error + ReadFromStorage(storagePath, path string) error + SetIgnoreTypes(types []string) + SetIgnoreDirPaths(paths []string) + SetIgnoreDirPatterns(paths []string) error + SetIgnoreFromFile(ignoreFile string) error + SetIgnoreHidden(value bool) + SetIncludeTypes(types []string) + SetFollowSymlinks(value bool) + SetShowAnnexedSize(value bool) + SetAnalyzer(analyzer common.Analyzer) + SetTimeFilter(timeFilter common.TimeFilter) + SetArchiveBrowsing(value bool) + SetCollapsePath(value bool) + StartUILoop() error +} + +// Flags define flags accepted by Run +type Flags struct { + Style Style `yaml:"style"` + Sorting Sorting `yaml:"sorting"` + CfgFile string `yaml:"-"` + LogFile string `yaml:"log-file"` + InputFile string `yaml:"input-file"` + OutputFile string `yaml:"output-file"` + IgnoreFromFile string `yaml:"ignore-from-file"` + IgnoreDirs []string `yaml:"ignore-dirs"` + IgnoreDirPatterns []string `yaml:"ignore-dir-patterns"` + TypeFilter []string `yaml:"type"` + ExcludeTypeFilter []string `yaml:"exclude-type"` + MaxCores int `yaml:"max-cores"` + Top int `yaml:"top"` + Depth int `yaml:"depth"` + SequentialScanning bool `yaml:"sequential-scanning"` + ShowDisks bool `yaml:"-"` + ShowApparentSize bool `yaml:"show-apparent-size"` + ShowRelativeSize bool `yaml:"show-relative-size"` + ShowAnnexedSize bool `yaml:"show-annexed-size"` + ShowVersion bool `yaml:"-"` + ShowItemCount bool `yaml:"show-item-count"` + ShowMTime bool `yaml:"show-mtime"` + NoColor bool `yaml:"no-color"` + Mouse bool `yaml:"mouse"` + NonInteractive bool `yaml:"non-interactive"` + NoProgress bool `yaml:"no-progress"` + NoUnicode bool `yaml:"no-unicode"` + NoCross bool `yaml:"no-cross"` + NoHidden bool `yaml:"no-hidden"` + NoDelete bool `yaml:"no-delete"` + NoViewFile bool `yaml:"no-view-file"` + NoSpawnShell bool `yaml:"no-spawn-shell"` + FollowSymlinks bool `yaml:"follow-symlinks"` + Profiling bool `yaml:"profiling"` + ReadFromStorage bool `yaml:"read-from-storage"` + DbPath string `yaml:"db"` + Summarize bool `yaml:"summarize"` + UseSIPrefix bool `yaml:"use-si-prefix"` + NoPrefix bool `yaml:"no-prefix"` + ShowInKiB bool `yaml:"show-in-kib"` + WriteConfig bool `yaml:"-"` + ReverseSort bool `yaml:"reverse-sort"` + ChangeCwd bool `yaml:"change-cwd"` + DeleteInBackground bool `yaml:"delete-in-background"` + DeleteInParallel bool `yaml:"delete-in-parallel"` + Since string `yaml:"since"` + Until string `yaml:"until"` + MaxAge string `yaml:"max-age"` + MinAge string `yaml:"min-age"` + ArchiveBrowsing bool `yaml:"archive-browsing"` + CollapsePath bool `yaml:"collapse-path"` + BrowseParentDirs bool `yaml:"browse-parent-dirs"` +} + +// ShouldRunInNonInteractiveMode checks if the application should run in non-interactive mode +// based on the flags set. +func (f *Flags) ShouldRunInNonInteractiveMode(istty bool) bool { + return !istty || + f.ShowVersion || + f.NonInteractive || + f.OutputFile != "" || + f.NoPrefix || + f.NoProgress || + f.Summarize || + f.Top > 0 +} + +// Style define style config +type Style struct { + Footer FooterColorStyle `yaml:"footer"` + SelectedRow ColorStyle `yaml:"selected-row"` + ResultRow ResultRowColorStyle `yaml:"result-row"` + Header HeaderColorStyle `yaml:"header"` + ProgressModal ProgressModalOpts `yaml:"progress-modal"` + UseOldSizeBar bool `yaml:"use-old-size-bar"` +} + +// ProgressModalOpts defines options for progress modal +type ProgressModalOpts struct { + CurrentItemNameMaxLen int `yaml:"current-item-path-max-len"` +} + +// ColorStyle defines styling of some item +type ColorStyle struct { + TextColor string `yaml:"text-color"` + BackgroundColor string `yaml:"background-color"` +} + +// FooterColorStyle defines styling of footer +type FooterColorStyle struct { + TextColor string `yaml:"text-color"` + BackgroundColor string `yaml:"background-color"` + NumberColor string `yaml:"number-color"` +} + +// HeaderColorStyle defines styling of header +type HeaderColorStyle struct { + TextColor string `yaml:"text-color"` + BackgroundColor string `yaml:"background-color"` + Hidden bool `yaml:"hidden"` +} + +// ResultRowColorStyle defines styling of result row +type ResultRowColorStyle struct { + NumberColor string `yaml:"number-color"` + DirectoryColor string `yaml:"directory-color"` +} + +// Sorting defines default sorting of items +type Sorting struct { + By string `yaml:"by"` + Order string `yaml:"order"` +} + +// App defines the main application +type App struct { + Writer io.Writer + TermApp common.TermApplication + Screen tcell.Screen + Getter device.DevicesInfoGetter + Flags *Flags + PathChecker func(string) (fs.FileInfo, error) + Args []string + Istty bool +} + +func init() { + http.DefaultServeMux = http.NewServeMux() +} + +// Run starts gdu main logic +// +//nolint:gocyclo,funlen // App function is a suite of if statements +func (a *App) Run() error { + var ui UI + + if a.Flags.ShowVersion { + fmt.Fprintln(a.Writer, "Version:\t", build.Version) + fmt.Fprintln(a.Writer, "Built time:\t", build.Time) + fmt.Fprintln(a.Writer, "Built user:\t", build.User) + return nil + } + + log.Printf("Runtime flags: %+v", *a.Flags) + + if a.Flags.NoPrefix && a.Flags.UseSIPrefix { + return fmt.Errorf("--no-prefix and --si cannot be used at once") + } + + path := a.getPath() + path, err := filepath.Abs(path) + if err != nil { + return err + } + + ui, err = a.createUI() + if err != nil { + return err + } + + if a.Flags.DbPath != "" { + if !a.Flags.ReadFromStorage { + // Remove existing db before re-scan + if strings.HasSuffix(a.Flags.DbPath, ".badger") { + os.RemoveAll(a.Flags.DbPath) + } else { + os.Remove(a.Flags.DbPath) + } + } + if strings.HasSuffix(a.Flags.DbPath, ".badger") { + ui.SetAnalyzer(analyze.CreateStoredAnalyzer(a.Flags.DbPath)) + } else { + sqliteAnalyzer, err := analyze.CreateSqliteAnalyzer(a.Flags.DbPath) + if err != nil { + return fmt.Errorf("creating sqlite analyzer: %w", err) + } + ui.SetAnalyzer(sqliteAnalyzer) + } + } + if a.Flags.SequentialScanning { + ui.SetAnalyzer(analyze.CreateSeqAnalyzer()) + } + if a.Flags.FollowSymlinks { + ui.SetFollowSymlinks(true) + } + if a.Flags.ShowAnnexedSize { + ui.SetShowAnnexedSize(true) + } + if a.Flags.ArchiveBrowsing { + ui.SetArchiveBrowsing(true) + } + if a.Flags.CollapsePath { + ui.SetCollapsePath(true) + } + + // Set up time filter if any time flags are provided + if a.Flags.Since != "" || a.Flags.Until != "" || a.Flags.MaxAge != "" || a.Flags.MinAge != "" { + if err := a.setTimeFilters(ui); err != nil { + return err + } + } + if err := a.setNoCross(path); err != nil { + return err + } + + // Process type filters + if len(a.Flags.TypeFilter) > 0 { + ui.SetIncludeTypes(a.Flags.TypeFilter) + } + if len(a.Flags.ExcludeTypeFilter) > 0 { + ui.SetIgnoreTypes(a.Flags.ExcludeTypeFilter) + } + + ui.SetIgnoreDirPaths(a.Flags.IgnoreDirs) + + if len(a.Flags.IgnoreDirPatterns) > 0 { + if err := ui.SetIgnoreDirPatterns(a.Flags.IgnoreDirPatterns); err != nil { + return err + } + } + + if a.Flags.IgnoreFromFile != "" { + if err := ui.SetIgnoreFromFile(a.Flags.IgnoreFromFile); err != nil { + return err + } + } + + if a.Flags.NoHidden { + ui.SetIgnoreHidden(true) + } + + a.setMaxProcs() + + if err := a.runAction(ui, path); err != nil { + return err + } + + return ui.StartUILoop() +} + +func (a *App) getPath() string { + if len(a.Args) == 1 { + return a.Args[0] + } + return "." +} + +func (a *App) setMaxProcs() { + if a.Flags.MaxCores < 1 || a.Flags.MaxCores > runtime.NumCPU() { + return + } + + runtime.GOMAXPROCS(a.Flags.MaxCores) + + // runtime.GOMAXPROCS(n) with n < 1 doesn't change current setting so we use it to check current value + log.Printf("Max cores set to %d", runtime.GOMAXPROCS(0)) +} + +func (a *App) setTimeFilters(ui UI) error { + loc := time.Local + now := time.Now() + + timeFilter, err := timefilter.NewTimeFilter( + a.Flags.Since, + a.Flags.Until, + a.Flags.MaxAge, + a.Flags.MinAge, + now, + loc, + ) + if err != nil { + return fmt.Errorf("invalid time filter: %w", err) + } + + if !timeFilter.IsEmpty() { + timeFilterFunc := func(mtime time.Time) bool { + return timeFilter.IncludeByTimeFilter(mtime, loc) + } + ui.SetTimeFilter(timeFilterFunc) + + // If this is a TUI, also set the filter info for display + if tuiUI, ok := ui.(*tui.UI); ok { + tuiUI.SetTimeFilterWithInfo(timeFilter, loc) + } + } + return nil +} + +func (a *App) createUI() (UI, error) { + var ui UI + var err error + + switch { + case a.Flags.OutputFile != "": + var output io.Writer + if a.Flags.OutputFile == "-" { + output = os.Stdout + } else { + output, err = os.OpenFile(a.Flags.OutputFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return nil, fmt.Errorf("opening output file: %w", err) + } + } + ui = report.CreateExportUI( + a.Writer, + output, + !a.Flags.NoColor && a.Istty, + !a.Flags.NoProgress && a.Istty, + a.Flags.UseSIPrefix, + ) + case a.Flags.ShouldRunInNonInteractiveMode(a.Istty): + fixedUnit := "" + if a.Flags.ShowInKiB { + fixedUnit = "k" + } + stdoutUI := stdout.CreateStdoutUI( + a.Writer, + !a.Flags.NoColor && a.Istty, + !a.Flags.NoProgress && a.Istty, + a.Flags.ShowApparentSize, + a.Flags.ShowRelativeSize, + a.Flags.Summarize, + a.Flags.UseSIPrefix, + a.Flags.NoPrefix, + fixedUnit, + a.Flags.Top, + a.Flags.ReverseSort, + a.Flags.Depth, + ) + if a.Flags.NoUnicode { + stdoutUI.UseOldProgressRunes() + } + if a.Flags.ShowItemCount { + stdoutUI.SetShowItemCount() + } + ui = stdoutUI + default: + opts := a.getOptions() + + ui = tui.CreateUI( + a.TermApp, + a.Screen, + os.Stdout, + !a.Flags.NoColor, + a.Flags.ShowApparentSize, + a.Flags.ShowRelativeSize, + a.Flags.UseSIPrefix, + opts..., + ) + + if !a.Flags.NoColor { + tview.Styles.TitleColor = tcell.NewRGBColor(27, 161, 227) + } else { + tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(150, 150, 150) + } + tview.Styles.BorderColor = tcell.ColorDefault + } + + return ui, nil +} + +func (a *App) getOptions() []tui.Option { + var opts []tui.Option + + if a.Flags.Style.SelectedRow.TextColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetSelectedTextColor(tcell.GetColor(a.Flags.Style.SelectedRow.TextColor)) + }) + } + if a.Flags.Style.SelectedRow.BackgroundColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetSelectedBackgroundColor(tcell.GetColor(a.Flags.Style.SelectedRow.BackgroundColor)) + }) + } + if a.Flags.Style.Footer.TextColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetFooterTextColor(a.Flags.Style.Footer.TextColor) + }) + } + if a.Flags.Style.Footer.BackgroundColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetFooterBackgroundColor(a.Flags.Style.Footer.BackgroundColor) + }) + } + if a.Flags.Style.Footer.NumberColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetFooterNumberColor(a.Flags.Style.Footer.NumberColor) + }) + } + if a.Flags.Style.Header.TextColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetHeaderTextColor(a.Flags.Style.Header.TextColor) + }) + } + if a.Flags.Style.Header.BackgroundColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetHeaderBackgroundColor(a.Flags.Style.Header.BackgroundColor) + }) + } + if a.Flags.Style.Header.Hidden { + opts = append(opts, func(ui *tui.UI) { + ui.SetHeaderHidden() + }) + } + if a.Flags.Style.ResultRow.NumberColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetResultRowNumberColor(a.Flags.Style.ResultRow.NumberColor) + }) + } + if a.Flags.Style.ResultRow.DirectoryColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetResultRowDirectoryColor(a.Flags.Style.ResultRow.DirectoryColor) + }) + } + if a.Flags.Style.ProgressModal.CurrentItemNameMaxLen > 0 { + opts = append(opts, func(ui *tui.UI) { + ui.SetCurrentItemNameMaxLen(a.Flags.Style.ProgressModal.CurrentItemNameMaxLen) + }) + } + if a.Flags.Style.UseOldSizeBar || a.Flags.NoUnicode { + opts = append(opts, func(ui *tui.UI) { + ui.UseOldSizeBar() + }) + } + if a.Flags.Sorting.Order != "" || a.Flags.Sorting.By != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetDefaultSorting(a.Flags.Sorting.By, a.Flags.Sorting.Order) + }) + } + if a.Flags.ChangeCwd { + opts = append(opts, func(ui *tui.UI) { + ui.SetChangeCwdFn(os.Chdir) + }) + } + if a.Flags.ShowItemCount { + opts = append(opts, func(ui *tui.UI) { + ui.SetShowItemCount() + }) + } + if a.Flags.ShowMTime { + opts = append(opts, func(ui *tui.UI) { + ui.SetShowMTime() + }) + } + if a.Flags.NoDelete { + opts = append(opts, func(ui *tui.UI) { + ui.SetNoDelete() + }) + } + if a.Flags.NoViewFile { + opts = append(opts, func(ui *tui.UI) { + ui.SetNoViewFile() + }) + } + if a.Flags.NoSpawnShell { + opts = append(opts, func(ui *tui.UI) { + ui.SetNoSpawnShell() + }) + } + if a.Flags.DeleteInBackground { + opts = append(opts, func(ui *tui.UI) { + ui.SetDeleteInBackground() + }) + } + if a.Flags.DeleteInParallel { + opts = append(opts, func(ui *tui.UI) { + ui.SetDeleteInParallel() + }) + } + if a.Flags.BrowseParentDirs { + opts = append(opts, func(ui *tui.UI) { + ui.SetBrowseParentDirs() + }) + } + return opts +} + +func (a *App) setNoCross(path string) error { + if a.Flags.NoCross { + mounts, err := a.Getter.GetMounts() + if err != nil { + return fmt.Errorf("loading mount points: %w", err) + } + paths := device.GetNestedMountpointsPaths(path, mounts) + log.Printf("Ignoring mount points: %s", strings.Join(paths, ", ")) + a.Flags.IgnoreDirs = append(a.Flags.IgnoreDirs, paths...) + } + return nil +} + +func (a *App) runAction(ui UI, path string) error { + if a.Flags.Profiling { + go func() { + http.HandleFunc("/debug/pprof/", pprof.Index) + http.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + http.HandleFunc("/debug/pprof/profile", pprof.Profile) + http.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + http.HandleFunc("/debug/pprof/trace", pprof.Trace) + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() + } + + switch { + case a.Flags.ShowDisks: + if err := ui.ListDevices(a.Getter); err != nil { + return fmt.Errorf("loading mount points: %w", err) + } + case a.Flags.InputFile != "": + var input io.Reader + var err error + if a.Flags.InputFile == "-" { + input = os.Stdin + } else { + input, err = os.OpenFile(a.Flags.InputFile, os.O_RDONLY, 0o600) + if err != nil { + return fmt.Errorf("opening input file: %w", err) + } + } + + if err := ui.ReadAnalysis(input); err != nil { + return fmt.Errorf("reading analysis: %w", err) + } + default: + if build.RootPathPrefix != "" { + path = build.RootPathPrefix + path + } + + _, err := a.PathChecker(path) + if err != nil { + return err + } + + log.Printf("Analyzing path: %s", path) + if err := ui.AnalyzePath(path, nil); err != nil { + return fmt.Errorf("scanning dir: %w", err) + } + } + return nil +} diff --git a/cmd/gdu/app/app_linux_test.go b/cmd/gdu/app/app_linux_test.go new file mode 100644 index 0000000..e206756 --- /dev/null +++ b/cmd/gdu/app/app_linux_test.go @@ -0,0 +1,108 @@ +//go:build linux + +package app + +import ( + "os" + "testing" + + "github.com/dundee/gdu/v5/internal/testdev" + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/stretchr/testify/assert" +) + +func TestNoCrossWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoCross: true}, + []string{"test_dir"}, + false, + device.LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"}, + ) + + assert.Equal(t, "loading mount points: open /xxxyyy: no such file or directory", err.Error()) + assert.Empty(t, out) +} + +func TestListDevicesWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + _, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true}, + []string{}, + false, + device.LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"}, + ) + + assert.Equal(t, "loading mount points: open /xxxyyy: no such file or directory", err.Error()) +} + +func TestOutputFileError(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", OutputFile: "/xyzxyz"}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestUseStorage(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + const storagePath = "/tmp/badger-test.badger" + defer func() { + err := os.RemoveAll(storagePath) + if err != nil { + panic(err) + } + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", DbPath: storagePath}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestReadFromStorage(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + storagePath := "/tmp/badger-test4.badger" + defer func() { + err := os.RemoveAll(storagePath) + if err != nil { + panic(err) + } + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", DbPath: storagePath}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + assert.Contains(t, out, "nested") + assert.Nil(t, err) + + out, err = runApp( + &Flags{LogFile: "/dev/null", ReadFromStorage: true, DbPath: storagePath}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} diff --git a/cmd/gdu/app/app_test.go b/cmd/gdu/app/app_test.go new file mode 100644 index 0000000..598c47d --- /dev/null +++ b/cmd/gdu/app/app_test.go @@ -0,0 +1,612 @@ +package app + +import ( + "bytes" + "os" + "regexp" + "runtime" + "strings" + "testing" + + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/internal/testdev" + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestVersion(t *testing.T) { + out, err := runApp( + &Flags{ShowVersion: true}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "Version:\t development") + assert.Nil(t, err) +} + +func TestAnalyzePath(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathWithShowItemCountNonInteractive(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowItemCount: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Nil(t, err) + assert.Regexp(t, regexp.MustCompile(`(?m)\s+\d+\s+/nested$`), out) +} + +func TestSequentialScanning(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", SequentialScanning: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestFollowSymlinks(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", FollowSymlinks: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestShowAnnexedSize(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowAnnexedSize: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathProfiling(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", Profiling: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathWithIgnoring(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + IgnoreDirPatterns: []string{"/(abc)+"}, + NoHidden: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathWithIgnoringPatternError(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + IgnoreDirPatterns: []string{"[[["}, + NoHidden: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, out, "") + assert.NotNil(t, err) +} + +func TestAnalyzePathWithIgnoringFromNotExistingFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + IgnoreFromFile: "file", + NoHidden: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, out, "") + assert.NotNil(t, err) +} + +func TestAnalyzePathWithGui(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null"}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithGuiNoColor(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoColor: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiShowMTimeAndItemCount(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowItemCount: true, ShowMTime: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiNoDelete(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoDelete: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiNoViewFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoViewFile: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiNoSpawnShell(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoSpawnShell: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiDeleteInParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", DeleteInParallel: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithGuiBackgroundDeletion(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", DeleteInBackground: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithDefaultSorting(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + Sorting: Sorting{ + By: "name", + Order: "asc", + }, + }, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithStyle(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + Style: Style{ + SelectedRow: ColorStyle{ + TextColor: "black", + BackgroundColor: "red", + }, + ProgressModal: ProgressModalOpts{ + CurrentItemNameMaxLen: 10, + }, + Footer: FooterColorStyle{ + TextColor: "black", + BackgroundColor: "red", + NumberColor: "white", + }, + Header: HeaderColorStyle{ + TextColor: "black", + BackgroundColor: "red", + Hidden: true, + }, + ResultRow: ResultRowColorStyle{ + NumberColor: "orange", + DirectoryColor: "blue", + }, + UseOldSizeBar: true, + }, + }, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathNoUnicode(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + NoUnicode: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathWithExport(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + defer func() { + os.Remove("output.json") + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", OutputFile: "output.json"}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEmpty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithChdir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + ChangeCwd: true, + }, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestReadAnalysisFromFile(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", InputFile: "../../../internal/testdata/test.json"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEmpty(t, out) + assert.Contains(t, out, "main.go") + assert.Nil(t, err) +} + +func TestReadWrongAnalysisFromFile(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", InputFile: "../../../internal/testdata/wrong.json"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "array of maps not found") +} + +func TestWrongCombinationOfPrefixes(t *testing.T) { + out, err := runApp( + &Flags{NoPrefix: true, UseSIPrefix: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "cannot be used at once") +} + +func TestReadWrongAnalysisFromNotExistingFile(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", InputFile: "xxx.json"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "no such file or directory") +} + +func TestAnalyzePathWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := bytes.NewBufferString("") + + app := App{ + Flags: &Flags{LogFile: "/dev/null"}, + Args: []string{"xxx"}, + Istty: false, + Writer: buff, + TermApp: testapp.CreateMockedApp(false), + Getter: testdev.DevicesInfoGetterMock{}, + PathChecker: os.Stat, + } + err := app.Run() + + assert.Equal(t, "", strings.TrimSpace(buff.String())) + assert.Contains(t, err.Error(), "no such file or directory") +} + +func TestNoCross(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoCross: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestListDevices(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "Device") + assert.Nil(t, err) +} + +func TestListDevicesToFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + defer func() { + os.Remove("output.json") + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true, OutputFile: "output.json"}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, "", out) + assert.Contains(t, err.Error(), "not supported") +} + +func TestListDevicesWithGui(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Nil(t, err) + assert.Empty(t, out) +} + +func TestMaxCores(t *testing.T) { + _, err := runApp( + &Flags{LogFile: "/dev/null", MaxCores: 1}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, 1, runtime.GOMAXPROCS(0)) + assert.Nil(t, err) +} + +func TestMaxCoresHighEdge(t *testing.T) { + if runtime.NumCPU() < 2 { + t.Skip("Skipping on a single core CPU") + } + out, err := runApp( + &Flags{LogFile: "/dev/null", MaxCores: runtime.NumCPU() + 1}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEqual(t, runtime.NumCPU(), runtime.GOMAXPROCS(0)) + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestMaxCoresLowEdge(t *testing.T) { + if runtime.NumCPU() < 2 { + t.Skip("Skipping on a single core CPU") + } + out, err := runApp( + &Flags{LogFile: "/dev/null", MaxCores: -100}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEqual(t, runtime.NumCPU(), runtime.GOMAXPROCS(0)) + assert.Empty(t, out) + assert.Nil(t, err) +} + +// nolint: unparam // Why: it's used in linux tests +func runApp(flags *Flags, args []string, istty bool, getter device.DevicesInfoGetter) (output string, err error) { + buff := bytes.NewBufferString("") + + app := App{ + Flags: flags, + Args: args, + Istty: istty, + Writer: buff, + TermApp: testapp.CreateMockedApp(false), + Getter: getter, + PathChecker: testdir.MockedPathChecker, + } + err = app.Run() + + return strings.TrimSpace(buff.String()), err +} diff --git a/cmd/gdu/main.go b/cmd/gdu/main.go new file mode 100644 index 0000000..6bcb919 --- /dev/null +++ b/cmd/gdu/main.go @@ -0,0 +1,262 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-isatty" + "github.com/rivo/tview" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/dundee/gdu/v5/cmd/gdu/app" + "github.com/dundee/gdu/v5/pkg/device" +) + +var ( + af *app.Flags + configErr error +) + +var rootCmd = &cobra.Command{ + Use: "gdu [directory_to_scan]", + Short: "Pretty fast disk usage analyzer written in Go", + Long: `Pretty fast disk usage analyzer written in Go. + +Gdu is intended primarily for SSD disks where it can fully utilize parallel processing. +However HDDs work as well, but the performance gain is not so huge. +`, + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + RunE: runE, +} + +// nolint:funlen // a lot of flags to initialize +func init() { + af = &app.Flags{} + flags := rootCmd.Flags() + flags.StringVar(&af.CfgFile, "config-file", "", "Read config from file (default is $HOME/.gdu.yaml)") + flags.StringVarP(&af.LogFile, "log-file", "l", "/dev/null", "Path to a logfile") + flags.StringVarP(&af.OutputFile, "output-file", "o", "", "Export all info into file as JSON") + flags.StringVarP(&af.InputFile, "input-file", "f", "", "Import analysis from JSON file") + flags.IntVarP(&af.MaxCores, "max-cores", "m", runtime.NumCPU(), fmt.Sprintf("Set max cores that Gdu will use. %d cores available", runtime.NumCPU())) + flags.BoolVar(&af.SequentialScanning, "sequential", false, "Use sequential scanning (intended for rotating HDDs)") + flags.BoolVarP(&af.ShowVersion, "version", "v", false, "Print version") + + flags.StringSliceVarP(&af.TypeFilter, "type", "T", []string{}, "File types to include (e.g., --type yaml,json)") + flags.StringSliceVarP(&af.ExcludeTypeFilter, "exclude-type", "E", []string{}, "File types to exclude (e.g., --exclude-type yaml,json)") + flags.StringSliceVarP(&af.IgnoreDirs, "ignore-dirs", "i", []string{"/proc", "/dev", "/sys", "/run"}, + "Paths to ignore (separated by comma). Can be absolute or relative to current directory") + flags.StringSliceVarP(&af.IgnoreDirPatterns, "ignore-dirs-pattern", "I", []string{}, + "Path patterns to ignore (separated by comma)") + flags.StringVarP(&af.IgnoreFromFile, "ignore-from", "X", "", + "Read path patterns to ignore from file") + flags.BoolVarP(&af.NoHidden, "no-hidden", "H", false, "Ignore hidden directories (beginning with dot)") + flags.BoolVarP( + &af.FollowSymlinks, "follow-symlinks", "L", false, + "Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed)", + ) + flags.BoolVarP( + &af.ShowAnnexedSize, "show-annexed-size", "A", false, + "Use apparent size of git-annex'ed files in case files are not present locally (real usage is zero)", + ) + flags.BoolVarP(&af.NoCross, "no-cross", "x", false, "Do not cross filesystem boundaries") + flags.BoolVar(&af.Profiling, "enable-profiling", false, "Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/") + + flags.StringVarP(&af.DbPath, "db", "D", "", "Store analysis in database (*.sqlite for SQLite, *.badger for BadgerDB)") + flags.BoolVarP(&af.ReadFromStorage, "read-from-storage", "r", false, "Use existing database instead of re-scanning") + flags.BoolVar(&af.ArchiveBrowsing, "archive-browsing", false, "Enable browsing of zip/jar archives") + flags.BoolVar(&af.CollapsePath, "collapse-path", false, "Collapse single-child directory chains") + + flags.BoolVarP(&af.ShowDisks, "show-disks", "d", false, "Show all mounted disks") + flags.BoolVarP(&af.ShowApparentSize, "show-apparent-size", "a", false, "Show apparent size") + flags.BoolVarP(&af.ShowRelativeSize, "show-relative-size", "B", false, "Show relative size") + flags.BoolVarP(&af.NoColor, "no-color", "c", false, "Do not use colorized output") + flags.BoolVarP(&af.ShowItemCount, "show-item-count", "C", false, "Show number of items in directory") + flags.BoolVarP(&af.ShowMTime, "show-mtime", "M", false, "Show latest mtime of items in directory") + flags.BoolVarP(&af.NonInteractive, "non-interactive", "n", false, "Do not run in interactive mode") + flags.BoolVarP(&af.NoProgress, "no-progress", "p", false, "Do not show progress in non-interactive mode") + flags.BoolVarP(&af.NoUnicode, "no-unicode", "u", false, "Do not use Unicode symbols (for size bar)") + flags.BoolVarP(&af.Summarize, "summarize", "s", false, "Show only a total in non-interactive mode") + flags.IntVarP(&af.Top, "top", "t", 0, "Show only top X largest files in non-interactive mode") + flags.IntVar(&af.Depth, "depth", 0, "Show directory structure up to specified depth in non-interactive mode (0 means the flag is ignored)") + flags.BoolVar(&af.UseSIPrefix, "si", false, "Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB)") + flags.BoolVar(&af.NoPrefix, "no-prefix", false, "Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode") + flags.BoolVarP(&af.ShowInKiB, "show-in-kib", "k", false, "Show sizes in KiB (or kB with --si) in non-interactive mode") + flags.BoolVar(&af.ReverseSort, "reverse-sort", false, "Reverse sorting order (smallest to largest) in non-interactive mode") + flags.BoolVar(&af.Mouse, "mouse", false, "Use mouse") + flags.BoolVar(&af.NoDelete, "no-delete", false, "Do not allow deletions") + flags.BoolVar(&af.NoViewFile, "no-view-file", false, "Do not allow viewing file contents") + flags.BoolVar(&af.NoSpawnShell, "no-spawn-shell", false, "Do not allow spawning shell") + flags.BoolVar(&af.WriteConfig, "write-config", false, "Write current configuration to file (default is $HOME/.gdu.yaml)") + flags.StringVar( + &af.Since, "since", "", + "Include files with mtime >= WHEN. WHEN accepts RFC3339 timestamp (e.g., 2025-08-11T01:00:00-07:00) "+ + "or date only YYYY-MM-DD (calendar-day compare; includes the whole day)", + ) + flags.StringVar(&af.Until, "until", "", "Include files with mtime <= WHEN. WHEN accepts RFC3339 timestamp or date only YYYY-MM-DD") + flags.StringVar(&af.MaxAge, "max-age", "", "Include files with mtime no older than DURATION (e.g., 7d, 2h30m, 1y2mo)") + flags.StringVar(&af.MinAge, "min-age", "", "Include files with mtime at least DURATION old (e.g., 30d, 1w)") + + initConfig() + setDefaults() +} + +func initConfig() { + setConfigFilePath() + data, err := os.ReadFile(af.CfgFile) + if err != nil { + configErr = err + return // config file does not exist, return + } + + configErr = yaml.Unmarshal(data, &af) +} + +func setDefaults() { + if af.Style.Footer.BackgroundColor == "" { + af.Style.Footer.BackgroundColor = "#2479D0" + } + if af.Style.Footer.TextColor == "" { + af.Style.Footer.TextColor = "#000000" + } + if af.Style.Footer.NumberColor == "" { + af.Style.Footer.NumberColor = "#FFFFFF" + } + if af.Style.Header.BackgroundColor == "" { + af.Style.Header.BackgroundColor = "#2479D0" + } + if af.Style.Header.TextColor == "" { + af.Style.Header.TextColor = "#000000" + } + if af.Style.ResultRow.NumberColor == "" { + af.Style.ResultRow.NumberColor = "#e67100" + } + if af.Style.ResultRow.DirectoryColor == "" { + af.Style.ResultRow.DirectoryColor = "#3498db" + } +} + +func setConfigFilePath() { + command := strings.Join(os.Args, " ") + if strings.Contains(command, "--config-file") { + re := regexp.MustCompile("--config-file[= ]([^ ]+)") + parts := re.FindStringSubmatch(command) + + if len(parts) > 1 { + af.CfgFile = parts[1] + return + } + } + setDefaultConfigFilePath() +} + +func setDefaultConfigFilePath() { + home, err := os.UserHomeDir() + if err != nil { + configErr = err + return + } + + path := filepath.Join(home, ".config", "gdu", "gdu.yaml") + if _, err := os.Stat(path); err == nil { + af.CfgFile = path + return + } + + af.CfgFile = filepath.Join(home, ".gdu.yaml") +} + +func runE(command *cobra.Command, args []string) error { + var ( + termApp *tview.Application + screen tcell.Screen + err error + ) + + if af.WriteConfig { + data, err := yaml.Marshal(af) + if err != nil { + return fmt.Errorf("error marshaling config file: %w", err) + } + if af.CfgFile == "" { + setDefaultConfigFilePath() + } + err = os.WriteFile(af.CfgFile, data, 0o600) + if err != nil { + return fmt.Errorf("error writing config file %s: %w", af.CfgFile, err) + } + } + + if runtime.GOOS == "windows" && af.LogFile == "/dev/null" { + af.LogFile = "nul" + } + + var f *os.File + if af.LogFile == "-" { + f = os.Stdout + } else { + f, err = os.OpenFile(af.LogFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("opening log file: %w", err) + } + defer func() { + cerr := f.Close() + if cerr != nil { + panic(cerr) + } + }() + } + log.SetOutput(f) + + if configErr != nil { + log.Printf("Error reading config file: %s", configErr.Error()) + } + + istty := isatty.IsTerminal(os.Stdout.Fd()) + + // we are not able to analyze disk usage on Windows and Plan9 + if runtime.GOOS == "windows" || runtime.GOOS == "plan9" { + af.ShowApparentSize = true + } + + if !af.ShouldRunInNonInteractiveMode(istty) { + screen, err = tcell.NewScreen() + if err != nil { + return fmt.Errorf("error creating screen: %w", err) + } + defer screen.Clear() + defer screen.Fini() + + termApp = tview.NewApplication() + termApp.SetScreen(screen) + + if af.Mouse { + termApp.EnableMouse(true) + } + } + + a := app.App{ + Flags: af, + Args: args, + Istty: istty, + Writer: os.Stdout, + TermApp: termApp, + Screen: screen, + Getter: device.Getter, + PathChecker: os.Stat, + } + return a.Run() +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/gdu/main_test.go b/cmd/gdu/main_test.go new file mode 100644 index 0000000..2c68943 --- /dev/null +++ b/cmd/gdu/main_test.go @@ -0,0 +1,25 @@ +package main + +import "testing" + +func TestNoViewFileFlagRegistered(t *testing.T) { + flag := rootCmd.Flags().Lookup("no-view-file") + if flag == nil { + t.Fatal("expected no-view-file flag to be registered") + } +} + +func TestNoViewFileFlagCanBeSet(t *testing.T) { + t.Cleanup(func() { + _ = rootCmd.Flags().Set("no-view-file", "false") + }) + + err := rootCmd.Flags().Set("no-view-file", "true") + if err != nil { + t.Fatalf("expected setting no-view-file flag to succeed: %v", err) + } + + if !af.NoViewFile { + t.Fatal("expected NoViewFile to be true after setting flag") + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..c9ef2c2 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + target: auto + threshold: 2% + informational: true + patch: + default: + informational: true \ No newline at end of file diff --git a/configuration.md b/configuration.md new file mode 100644 index 0000000..890b570 --- /dev/null +++ b/configuration.md @@ -0,0 +1,197 @@ +# YAML file configuration options + +Gdu provides an additional set of configuration options to the usual command line options. + +You can get the full list of all possible options by running: + +``` +gdu --write-config +``` + +This will create file `$HOME/.gdu.yaml` with all the options set to default values. + +Let's go through them one by one: + +#### `log-file` + +Path to a logfile (default "/dev/null") + +#### `input-file` + +Import analysis from JSON file + +#### `output-file` + +Export all info into file as JSON + +#### `ignore-dirs` + +Paths to ignore (separated by comma). Can be absolute (like `/proc`) or relative to the current working directory (like `node_modules`). Default values are [/proc,/dev,/sys,/run]. + +#### `ignore-dir-patterns` + +Path patterns to ignore (separated by comma). Patterns can be absolute or relative to the current working directory. + +#### `ignore-from-file` + +Read path patterns to ignore from file. Patterns can be absolute or relative to the current working directory. + +#### `max-cores` + +Set max cores that Gdu will use. + +#### `sequential-scanning` + +Use sequential scanning (intended for rotating HDDs) + +#### `show-apparent-size` + +Show apparent size + +#### `show-relative-size` + +Show relative size + +#### `show-item-count` + +Show number of items in directory + +#### `no-color` + +Do not use colorized output + +#### `mouse` + +Use mouse + +#### `non-interactive` + +Do not run in interactive mode + +#### `no-progress` + +Do not show progress in non-interactive mode + +#### `no-cross` + +Do not cross filesystem boundaries + +#### `no-hidden` + +Ignore hidden directories (beginning with dot) + +#### `no-delete` + +Do not allow deletions + +#### `no-view-file` + +Do not allow viewing file contents + +#### `follow-symlinks` + +Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed) + +#### `profiling` + +Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ + +#### `read-from-storage` + +Read analysis data from persistent key-value storage + +#### `summarize` + +Show only a total in non-interactive mode + +#### `use-si-prefix` + +Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) + +#### `no-prefix` + +Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode + +#### `reverse-sort` + +Reverse sorting order (smallest to largest) in non-interactive mode + +#### `change-cwd` + +Set CWD variable when browsing directories + +#### `delete-in-background` + +Delete items in the background, not blocking the UI from work + +#### `delete-in-parallel` + +Delete items in parallel, which might increase the speed of deletion + +#### `browse-parent-dirs` + +Allow navigating above the launch directory by pressing the left arrow key. When enabled, pressing left at the top-level directory will rescan and open its parent directory. Disabled by default. + + +#### `style.selected-row.text-color` + +Color of text for the selected row + +#### `style.selected-row.background-color` + +Background color for the selected row + +#### `style.progress-modal.current-item-path-max-len` + +Maximum length of file path for the current item in progress bar. +When the length is reached, the path is shortened with "/.../". + +#### `style.use-old-size-bar` + +Show size bar without Unicode symbols. + +#### `style.footer.text-color` + +Color of text for footer bar + +#### `style.footer.background-color` + +Background color for footer bar + +#### `style.footer.number-color` + +Color of numbers displayed in the footer + +#### `style.header.text-color` + +Color of text for header bar + +#### `style.header.background-color` + +Background color for header bar + +#### `style.header.hidden` + +Hide the header bar + +#### `style.result-row.number-color` + +Color of numbers in result rows + +#### `style.result-row.directory-color` + +Color of directory names in result rows + +#### `sorting.by` + +Sort items. Possible values: +* name - name of the item +* size - usage or apparent size +* itemCount - number of items in the folder tree +* mtime - modification time + +#### `sorting.order` + +Set sorting order. Possible values: +* asc - ascending order +* desc - descending order diff --git a/default.pgo b/default.pgo new file mode 100644 index 0000000000000000000000000000000000000000..dd55ccfd4bc03430606b97de5f27d4c944907a4f GIT binary patch literal 62946 zcmV)%K#jj2iwFP!00000|CGIVyd1}s9{THC{dKDDnYlB#h#Y1@)FmWQB()0mTGSF= z!RGGDZ@sqlR!=scwAQ}oUCFXnlK1%{U*t$6f&hq|0})8%oQOyOBqEVX1QHoUBEtK1 zPtSB!bzk60`iJP-)1kWRobR0Tov)x0iONukLe;>5D#?mUCE7UWq#95dnr@Krh(a5C zPjdqb%|!U`D>7~TWc@KGaG+SWL%&TM@2~1A2YHy9>rp?*IOn$pps824uZY#IGb(^+@>YV}@T6|ALpD>X@?}Y4{}n@Q=Tie^Z;aKFELn?f>|n{}f&!PaAv8 z``dUT|3;;KWZL-Vu1jW+1Ah1WKm6VQ`se6IweP2EZvFrFzYFvonKtg|eG=it8l|pM zH_3LWMjKCjUJM>beRrvc)Klsu^_H5XK2l$)pVVI(APtlTN!3f0;0>0BNJFLe!=&L7 zdlL%dO{q)t(#g3aq>*GEpMzCz>RM^C-TE3vIGAMJiOsH4kg5G)-zhU78`yl$eXolA`Yx z&!-~X+0q>Lp^`J_O6-f(!I>w`mljBW_h0{67=m{tVfLsC!{=^88^2iFQwj>3AUMz*PDt?p#>?P8B()-d^W?&T71Fjh!E55H@&P*+O7t&Cm# z@@}}OexuUAqKzX@^;5!UTqUiRc$+a#S|h2njy4{>_RH|!Yo&0J#PcEttdrJD8>DFI z#+RiqHcFeM%@TX>Ez(wrefv5%A8?=Fq>XQW`CNE>o3vfpA-%SfyJ!)PwoBSAeJJgz zoc|(i{P4(KDQE*0fXtQ>GmIxc(*3S&gLe2z*qpp7%n|GV(W38`Yg zli7SyIwk!fb|mrJQy8bEPucHilg}jLRIRB8{R}tVT7jOGnspiHDzn<7jg!{@S$OgD z;aDW@F9&=seIZ?tSj$@v|98LtgD{}A8KYg4!ojS4755T9fbkzdE=$#yvdI-@MLOiF zM2m4 z2gonI%sLkeW1w7F^zr;J!5twDk_XGRl~f(SPIzXBJX9Vg50~GRN5~`PQS#`F|BsR1 zl9^RJR(@L^Cy$pW$nD>e85cjXb}Y6Pd03O=$+e^L5)5EYsU2gCvOG;5FHdK$*%tX3 zS&xz|sF}>=6LV61K<;9Bn6qlY)3&s6^yI!$c$Y2mujQ@s2eQi8vA^CH9f+T> z9{zTDhrCnXCGVEoe^`0?+9b*Ck@w2`guniVHoiA^ax?}XvCB0Fd@S#mV?Y*Pqa(@( z+A(ay{gG_volX91e z8;uV+P*2HiYtGk&c3KWiXY3sEus)SPlLdfOfPF?jEANzca#r4jdMhC@1y%p9H7hpFjeUt@K?D3PQ z?5i-J%LnPF#5TOWv!@CVsA{1QvFvcLI;x&Ij8;32LEe8%6 ztdIhIiZ;$2y*Pq2LzJOPdMFI$6^1EYLbMkf9v9khEb|r zpLtUsWU0d6t`5#JWx29lx)MW-nrp5EtCf?Vqm3sgZjr)oxy-+;PH_1KWuvl5 zVQo+z`0E?eW~Kd>@P1XFLt$)HDv5!bVg7)hu-oE`Cl9q<*{JMLIt#dYr}FqNB@4s3 z&~__4O#D#Uqx?istBVa}uTp)zPIzXYk_Ek*2V!BIqj10~I>KR@0OrSv;KB>A_A7}` zex5d-+xoE-JciP|=I)rl(gO*C?^#L?j+yxlybWEgZOA0Hha+-E$%aE`nO8{c@i~Q@R~WnnsQzFQn{hrRBkD^A7L|nkP*@yg$)>Uz*kBwjhHyBc9ENn*X#3m z;;wQ}xvxA>8r81qqn<2)*-icB4_|$yyUO~!9%_u9l2`AkM(!(~ZZ_$q5;ld{TivK| z8N(dVq=u`KwO1bWJ}L|D9H@QO1{MIGlxw~xHoyhg{h}*%<`#G>(Ee&@6W2?x?UXw6 zXi6KPzL;Q?0A?R`pgKsMl7yo^$Y51Wz-U9%H`=nNXu2^}4MX(U=LRrGNW;{hvdE?m z&Tus&&E|u=sg6)bs+_4hDUVXCFPLPDN2_d(VT?*t=3U-W$EvjQkLZ75YbGRCH zP#BZdN-j8=JX2KRniX7CK{8X-n(cR?O=G_yg)v>N3}u4JXQ&AVx5>i`2zxgol%n3BolK17{KQCZAK~-x+r(GO!}{F{wRU1`j*4(ssvPu7=Ibhzalsz+ zwGs=neUQ~^oTNd#K8WW=0$H}H ze5&uDv|VKa-5OxKDv?^;RDiuhO@F@?eOSf>if`KpGH7Ja|@m1p`-5zyf-ib#-KFEyfA!e|YtaG14n|>c{GS z^?>?NFL_Wsq;gM8VH{S)RMbr7&EjR5hjoM*g(RN+L_MnVK&Amt9aE31C)AT_=)oTi z5<20TQ_K%et4HJ(;EGRGzCcib{rVoj)gL8E6o-7Fk_$3}2YM$UA}QouP%l=@VQbpB`dFhBo_I;UtX|=a zB!zKRsO16kFoyc zmU>%FLaOi~)Nz8VwaHg1VUx*s)k=0MNzdFTz`$q##LOd5{DXwtZ1Rz3XCgYm`7B<$>?)v^K!mhG#t1@b(sep*s- zNF36#N)Cs~2rC|+E&}PVC0}rWMp$1yLOIN&hLQkkpjJsn{D>=?Hp$U}1YO)zfASlZ zv4exO!CH=k(nGXps1jf}R2!xZ*TzV1Y9lme!H?8NY0)zzD71#Ij!IQ@P=q^L%R+&z zK*wl8o-U~lzZKn4{7uyl|B6dY1Te>H8+fdqhZR>C*$GpG`?fYN=sg3&t zji}5g|K0C@UM<@uYvdjN)#73HK_+T^(x?D?k~UcrFA+{NwT5wdOW7WEX)npTnT{x@ zeO7a>v{q%9E;yGdS}O3)!anxoCt=4tb_mc^RwP*|Y7t1Z+PX^XWb+Iw32_cgX$QiQuyTc$Aq ze)5*fwH4Y*jmsj$kYl<~)HF9w|KW0;{y-+ZLKwv%PPXXqy6%Suciu+9`vs?b_Mt#cQyX? za=>J@HU*OpdQS^47vDKv8WhGgiOY;c194w_pj}`CutuGGp`;AgRqv*YA2<)IyZ+M4 zuk_H_fop?YPrcbF8l;!b%%$EsU$bn|8`v5`AHA~Zk{Q)k=L$66LdSl3e|>-+d%Jtm zK)prIdyqa^OgXnlT{?ds{VX1`d^Lq#akh!yo0JYHzlO)>%7Y*FVC(}GU=PvLhg&0< zm;OzR>Ym3FL-k?$4dv#;L(1f14cAldmBM&aue=Vkl1W#2EU^-GdKymC+b&~Ysw_OWx;L< zUM1L^tXB!8N&0b$&YAMq^Ztk{DfiLhrN#IhSzK=p)Tw&nd2_%tUG(8axYPBRp2vBC z0_++3OnsLADi{9-FlX!O?9-g^!ry02#A%r=Uxyf7Ni0b%YMiSld1@bIo}S9r&DXi& zs0V$4F8Grsr5ZsdpXpsajr`+?DoF|_eqxcjSmj6`i3=O1bg5dAc(4PQ3-wnP>3`2> zBNyv@e7Zzu*a7kNI->lZ{(j46$x@w9t0tl4q)P5F@xdWph>|ekXI!RNJP@BoUam8z ztkBag(k3f)!n(&*I$xcrhd)9R^S<%(fUIUs=^8zB%kkm^S*tVid!4>skDVSD%XU}U z!QG(qwsxb=`M(?x#_%y#OXmM3W-Zmh*{pBTQ-$;*+^D{unw}BEhf5XIs_5&b*93;_ z!q^?8h)B0MfV@>tfm9#l1HDUSmM6$#p)i}a=?s}Cx!xspyS_u;sqfNN28HYnUpxM1 zYXl$adl;|Bni3b1A$QD2$KwpL4c{?AOTvg-NN- zlVWt0jL-pH{1!Rjpf1`mn;g=U{Fw*+ur4rej_6EHClBirJr(GF3muQ@jMHnL6Dee+ zkLhV*-*H{wLOzK$S@m@>jIVJGIWHaJWIclbFq^-J2mPcjguAEota`RXPV3}Ty*fZ` zko!z;w)kBKCk%6v=s1al&*-VlKwVO(uV9KeCJ11j)rE5%a!w~J*c+YKKi9v|FX)^L zZv`4U!;AVQ{c_d2$K%xr&s@>3>VHf|yA|j)eOP8nsQ^39z;GtggMM9SjlK)*OP#5% zHsGmB@%=^%4Yv#Jrq0jMTRKnjzO9GR-5vdcK&vajt{QS7Ci_ZfmP8J?tJiQr64Le5 zHY#W3<O`gMLr{6MmiT*Tl5AP40*5EE$D9D(};GYeEPUo4D_@(By%>NfB&gqru^q zJgjosm^o0p8l3Sxp>g4u57NyLtHVZjgNw#nf%Y(Z8oi8kM}-G{q0Xi{a==FI57iW7 z@>ac#CS$P1B?$W%i7RZtQ+85x2kICjabpz5T1kY0ZyAwiiiZ_stigE9 zw~hIdNa$+?dPg2-WCfBPQSPnWlg1kpj7O)P0J?l8ibAR7aEI<#l{ljJ>z|&n%Pa(&Qe1Ns{@$JjI?`SZlsCvD~y#! zlFVklC*#j+?st_Dy5D4-tTvhh7!FxuFh25e)UP$x8S9M=M)Q*|4{M__IU`*DtDjW7 zXY9A?;cqgU$v78aZ#L5N?MbxRVr(@&Fe)}}o00tlHrdXMy90HH@we=k-)V@bZkLf! zcHC_YHPSB7h4!Jrv|SeA?lD5QCdM{C$X+AGN`F0eYDoyW&*0jP(xhJIj}*pfxw5e1 zf<+Dpo%IgUseQz+_Q*bQ8KHXkwO~PX$o23KN*^1U!JP|jzrheUe2~{S#njYf{T?uw z_<+JVX#A8-Lbn1v)QnB>pfA^=Dt9vAhmD_bU1Ue10T)L06N3x#3$TwGX+IvoJSmS- z#eTsz)mKE~%`tqRe_{0i%3sRE0$eS&y23$QEsnmE8rPCIAt+_CcpmpK325Kz|_hM3RxL1ydx*iaZ3jGwdl zojN#ii#0z$MYtD@tOR!sc%bi4Qc35S_rN9TBXT;|isnGQ#GC5NF^e&&Pb^Xwt0S|T zgG=zP2s1mm|5Zb%Hnc`?&A4tv?T)EbGuzFZM4$!Ol@`Bv)Dk!KrE$aHn)Mz->85eZ zc$h1%hkx6+V|-;~v|NJRHJF^sJ%hCYIpDtWz-T1Vv&Fg99MF{<7rHDRsND#c8;Czc zhza8#`XJp&+#Zww-X4S>!B(Kv5`9K=)01SDyL%CCOmaYPBDP0HMzM(qMlk_v5A@g^ zBy90{65F(yG+Fc^C6!HFNMXpu6NX%9eMwFr2xK$u>)`Yw86@m)R3pwcK$d=(>fg@lJ4Hkm_sPE=HC7D~k)^tpr;M`#5)j|lK5d5-ynFZ?c)7s(MIN{XcK z^6h2`eS87ot0D1k6Ea} z3UsyTKN3Vx@d7Cv>_J~cSaw?gb1j)KafHB_sP-5;)%Iyoc_}{QTjTP|-SyrFN8y6q(M7V$N|jwdzO+d+1cU1T@;knAB3r#U~4`n{ye zhHfr+bfN7dym~@h?qjeEa957&XFI!Dz>hlCk-s`_H)8z=K`2ba`nBF zri3qeqd{SWmu5m)5BddiK~G}7R-hM&fDM!He~J8rOLtu+8J zRWwUX`u8+{n<6duGP5%%d04&8RD!W4hfz5iQ(^M3n#@#m5r$i7-w;1y2Nao#99Ofe zRo42Lq_4@iY!h+iETMBPQ6hDaWIRUdqx6IdlYG5?rl253`kN`n$p>2MtKZ{khoKEn zxzqtD{pVI#9=$4cwGe-ZICUT)oJF`nJh^p{PwPDZ}JpjWbjHLKoV2bArjbrFYCM zn`M)UCShM_k||bPD2&M_X9_&%Q%ttMC4f0q9I+C-eOlZT+F6VTrftX?M8UA+ zk*1p)mHDbLb^GX?8>%o)!a+~W?DtYJ+zhj`z;XP=FU7hS$V`*5ud_^+XyiccXV?yN zli8+N;+$i~QD*`@FE$g&)WMl+vI)4ve$F$QNYH^gpPwoV%v7XMYfF5L^$-70krzvl z+9Gwa8vD3pVBR&kF4Q*2Ei@OIi_Ilw1{Qr0Ut_R(r}xa36;Z#1j_;dq%S+8i!h9Cu zzl?@uW_)mR`^v+rXcTbXArEU(T7NPRtE-tDO_u5a zm8^9^p&95=aK%ZcQw$v~boe=1b| zPnpbw4Phsv9hMB`X)`m9`qbp(=+8{X?ib;nF|$072mLJP;c~z^Q{)wW9Z#M&AIdjR zQQ*%iDInbz`OnRVTEj2QbRK+&m~Lk@?znSXfwC=lY}!348Hh>u3*yZ@=od|vo)?k4 zx9gY8%jOmHs>v|k3b3!4*Hb3eAzzvdZ@>q+Ve%9$_XsOEi1G54Q zQ%yA#Mk6@&2#~H|kbXDtgtqQr$=MFn9^e|Ldm^jS=7aPCpDYMqjwC{DmN8Hqx(=Z_ z9;CVOP2kdBeK4F0Zhep_Rl^qTl6zgHpT~O(o)`c2vTFy9(8x*p?G2#hNDuC6)N){^fxgABf(S{ zM7KPE9yvzoF&!R&`!gV|*;Neu|Jgj#xNGt_X7!$#vy8z}Sj8AExo;Q}<6km?KqnF?lVO#^R7U1-z6n1!d& zZU$K9ZgZJw3S$xxy8>q-g_UL@N>_7pWS&%`ttVBa-s(o>fHoT!#7sgMIC7+vIyjYf zOu>}qVfE2c%fu#gz-j7DS5i1A2h2sSxRiLrJ!02zbc?LY(LChxe1%c~YSp?XN57qq zY<@xs-U94UI(JTW2v^xlkFi1TyP{c32CugwSlR&QLh^@{4(CGfgxY8zc>~}L~E712KWPp>0c^^>jpMi&P3`qy-XW(Ao3(7X z>bXq&7r;CZL1i+k;dT-PujAetj=t_f`y4+>f?^N)7Z5~oPLmW_F~k(6*q!e^SaOIMjSzeWBvGZ8)Lmkc4B zeGMg*p)_4*zXFBvC3>);HuI*0#D9Y3Y91^%QXYLF#X>jzuxelS&K z8760t1BO_l3P1pJs3jE2Z8FRvY$j&7l@bnq(_&iK0+=Jj&*eZJX>q%njj}j?&SfsVH}Div6n@}N<-1VaRg z_PRQ*9ZoaTBOddxOvRZZ+zFX4(iZu5tce!0$hc4Q1X?#rU8Q?qPsy_Ty)-fF;y|5b zan;{55?8S=z@BWKl4|AO#6eB5(v2NH$W)7Gs25;Qvof0xW}APg_7o&@Y_24H-I|IY zA4WuNj>4&}K&M;TAz>a?+}A5O_O%k{*ljYy;R;Lg`;ZCvnuK3Bnvg)Vzm0diRYT+q=MP9 z!kP?w?H;HF|d{Hh$)VoUHS4q0L`S?@YH zm4sY6jqsk;MNl_*-{N^-OD*2GRFoz~lj}fT#;lys{aYNMOc;e>WFT(SwZal|3@a=4 zCpj>~6!>YYEIuQ1jwJF#@mcLq;9E4(eDMPYYw8QIS6k_hiU8&sOCTNRfY79UpWG*=uDq&VB>WzAOKcY=-aS zMTR}Gyya#6`0-!B)BCJH>W*=HFPG~6I-WeDaGa5cOIwu(ep_wx3I`P!VQgbK)NKBbJa0|HSITH}d6S?KbAC=~m7rS=fTXg?5y6 zQYCoDtXR~Lx9E9TzxZWJ@y&&H+~O&WCoImI=V6_+PFX@NFK8Tp`38GO+S^fRm3 z4&3e<8%n#-&R9HoI?{At8S#+{G)u(|V4k(osn&C>4Q{|w=dFqmTg%*joYE2XM+&eH zN}pRIXw4+Mg59wM)btg*C>*Gt zt6bZ6Wm^MY#jy^~U2C3|Qnw0Vo`6ts@KJcb#S`d84apMxHB@ngF|}Qgi{ReY4@_* z_qIi4rqJTqLX6;$J~mTob)ogOnRU?-<$iX5dw|WZ_J=g$477Rb+aNm_nq1%sahhS?(5pdS8Q(#&SIIbgWmMd-Dm zFy6Gq6w(NLq%BUf%1$63ak3VpY|ccCwqHw`#W8j!`QRlk7Rzy}#?^9;LiAjR#BD2%u5ZW3Sf9B1!P(!tGmo6BR*x7e_uN;K)t`KJl?*SU6| zH+fRG5Avn@$|30HKw%`1C2WQI+WELb`P%CIw9fzGrS$yw;96=)bJW&O#4dV_EqSj@7S zeOVkb+a_GY%|U67&3c~N&Rzr9&Bo^1sV0^FW{Ss?17>6yyLooH5;)&ZH7Gug`fN%F zo74C z)Lv%uozmMB4i3~dEk6nogAOy&LaEE`G?2*wD^k|04RR~(sw`J$(ctA_9n@33Nv#pA zvQtU;1OTrVhINgd+Q~4M_>1|tkFhwddn_Bn#PIAw&%>InXDL9hvwzLZ*#hhjv`k{h z7{V1QU1)p3H>E95>IEg+^>&6z?FKt*Z&MHYMw{&f_CaV7DOx+5qMz`-ZXE z=FQPBe#yz%dC=#WY$?)%zQty{>_T5?r`nps@vyhq;y&^nQQmGRA99Di)81u2q>8;; zu=*bK4{brudWpTq7S*wm7vF1(WZWX$efH>V9>Pa9XTaMaSK9#dWBZo^SChinZ;Qp- z19nXiC3%g33csUpXwq!AzBLa$4%t~mxx@AmJF`rDR~6IU6vihux3NbN?ooc)B*yKS zeOD4xOg-qcq-cF4?%}w72rR*gX@N4@?b`>Nftq>Kmsnk`6n z3{d&WSA=`rjGNR3$4-NL1R~k>vQks@Whqw zPMqpVT+=L@X{>CGpojAiV{%Zcs2C>&$3a5!IR^|WC|<4HKG`tq9cQ9*3y0)K@2>1Zo8-K~af~NBRdARb``z!;9OEetS74s% z5Y|LYb1IWiAQTLBQZ3EX9bwkox0$(6gge7&M&>U{W{TUHN34$&S$i(|xv(YBOo!hu z!eWX_75G=lV5beB4>HS1^}5Y=VkM0nZl(F za==PEqe*VK$=$$(HlO`oZINH#@T>}UVui5I;iG|W5Qzs)s(;gi{sN~`>w}yZGL@}B z7dhGE@(n!OA}u2!;$G}*9HNwitvtaKh# zfO5zxhe=LS7^|H%4ucBYqi#!fvv4xxYaJf;+#+$NIKdU`oCzlT5f&heg|^-ibRIT1 zeE-o#=P1%3aFe+53$QD)>M2)pUKIpmCJ~`S^{5$d$|1!0p}|VCjF}*v+Z=d}#n&Y^ zJ6u_4izB24w>n+;?0OiFI%!5v4)|GW*9#ZgHiu7_Zg(c9bkcp0QvzS073hv;ybOi0 z)7fn?T0I5WyBsFjA7A5XwA<~pgftL3os=v{w38gioxZ1~WE?(pxN+}*BA?;QN_TWK z1Va~2IhG!n16D|)!bb-b`To3ALv9_MiE2jMj|Y8^v)8%6X1w+}f@}Q95t~v!79mz` zTPSV=D3JXQGwlv!0k%A>gHDRgL}46q#MItlhjT06#B)cSN*8L&=|&#(zvN7M&68!(Mq{R4)^`*;9N70JE;|0p+s?AU?-dm zYUN3%`oQtA=kdfT=d|-j>;n`Uphy59)tB_C!|1lV&^~i`TX{oe#K2r=XB?g~bkIeB!)Ga-qu(_hsY# znzC~1VZblH^fHU^+oSG^lhjMoyLW+Hb(r5@bNG!DeUR%8Z;~Tf;$$1C#B_b>v{3f+ zXIxg)2f5)aS8h67+4PpUm;;!%GmhPH(rxb(#kkCc_LalS9Nl%Ywu7kw0FiZHk*=N2 z7bEXEBxy6MhkxJMA!kN(&*O;)PNN&fbmNr?k3{5kaQ0SI>rL8J8Uv?B-#`bP3ixhOZ*A#X%2xlgq9J z3ZswPMGy!y_YBk_2iWzf%MN(e5r{w}ZGHNb6Pn)wF0%+*%dDaoTjF0alq*iCJK zI>arO)MU4ip)N~Z7gIkuATlMQqS-;|zIKfnS_)&b`tR8sF@-VA<>)VoVtHlmB-NJT z?wc-q^?}NAWi`Kp?Fd)IUIo};h%R;zdC*6?f_oU{^4o!ocE`AHxdO4_Yj}K<{Lq~$ zg=*ngH*JQ#FomGu1w8$>`$v&3YM4W8VISv;8Xnb6sJH}J0CT)6ln)#5)CBh(x6+8- z5?R$)+k-QI_3$UUlicPk+dZx2maR2{%2ond?#qQX+2y;mMo3d!oApmq-IPfjZlt0H z2kJDpEnDfE3$Ef)fITAKenXft)7>1$aPF<7anB6*4Q|2DbW=(}vs|7EvWI6^6=2VH z(+Q1QkK|)ayfeoo+?%+s3GHnUta+{|A2;8Pl|WOe^#v|BR7JS&x;vCip`?W_udL)i zUF4S71-00H>E)E}o)2q-ND3X;6VLm}JIokvNX2YL$@pT=Krl zyaOv*&glXzbr~DsgDi7-PNomC+~qM-9@YpYMdd2OUEwyq1(^a5`U$YymyUuOi&U_osGkeglcQ z-QqT1k}blWmsx20F1~qyCmL^(wzl}P6vhX}wfT|Ps2h6q3T*j+7XKks)d z`b9?}&=d~1^=VbcgD#(CEy6wI4$mMtwgNq?#{0Po@9IK39084FFq$!XaykzK<6xl9N1}{F`Og)J< z7u`ooQrP4YGgC|Os?Bafp{@wGk`KHh^BXpY@tVt)2OX#v9F9nJ#qG@RoN+b0Wzu+Y z%@xXg=SW7flft;pW2ro>4O*&_>X0v8KAY1d^GW9-+#8v2!G(6y<@Vjbv<<#rHa%Haf>8{J0?jqcKZZmw0`!25=QxCsZ_sU3wJ#YnjQ$=Ns%OTu1 z*s5#}xL}A{8m$pDdR@J2Pty_QQ%*OpWr4p3y}KuLll1TyYo7zA+8Hd`n-T;0;+>j8 z?;*^)o*pZePhs3QI68O{ZZ9tj{Z)GExo-5{-YAV7zB)LQ)kq^Rr8Lsyy~uSc_wlk? z>H2z{4pwg^HKD(W=PD9vBGT*UH4n*Zf@sr)%|ZHmDNgLmXyBBBx3>5$MYsdJfnH`H zd_FB2pNBQb%d&xky&;|u_SVB6>J9UTd+p!!MD_R(-FqXwG%IVACm?AGW3(sUZH$-N z?j#TEEic99eH!h?dM!zay3pSC-rx!@aY~W>wDeB$O|qy7I>+NBH|BcT)|tYX=ZVGBW0t@V-m1r7E%7GvJuX?hP!|+4 z7U~&f(>44amrtV20`DPnJ;@-v>v2T7g&vP_7kPpKUhJ{^KNaCF@wnYD;y<)So+pa) zl}{Jidmf)HeBTrL372|;9S&fQA;L!tU~bFU;9#*UZhO`WbeZ_25)(8}N@*J|Uxwpc5G*zw+&Pp$%4=WFAm6u8?=3%Y&Qlz*5<{B?;+eS#q?)VAcYpu6PPL3x7 zVXpIj9dE`FpZpj~>%GdZzRlstm@<4_6>{qbr41efgI#DFJ${e1O&+Hrxi$+{f^7Df zH=mlR$3E3eRW29nY1yc4ijeHz;-!AK)l0|ZlcEiV5>NVpmxVpId161%P3ik=zv=c2 zi%ntd;Mctmvc%(yiw@MC9)~C@Ew+4z=CkOqtA)5L2kiDTHktp>6S~OucpQUoulE=` zR{O-U3SdV03ZcUFk;jet#~xqaXoK8-22BdztpsnovQZgo%tpGs?*Q*_>frRy`Y0hL z69q*NdSUd-+CdNcAx}&-cswz3tInzyCCloFcS=fkhE|HLkFgoyPrUb3KJGv2aazeE zgqDv2>|+`8#DRL&OmYzpIqosYwjByQ5hTq8KH+6`BjjP-$>d9(^cI<8+*6)VZuvT% z@(+0Up_2#m7#k=aIw*y+FF6Q@F4sDYul6}v}!urm`y5^-?zY4IgXH4T9 z@TFJt{z-iy>M`Y#>mj~0c>S+>_&2=DZVFlbHaERy1Wj*wtgv+}(A&+pb`Sa;kImgz zMaUSH-2&{MW~E%g*2zPdY-X^be2}laRQY(L&IE{Ukr(Fx=m6yI#+9HqAq}WTu-Okx>LcZJc|xhx$Q^QTEB_sdQes=yd?!% zA0+e~>=Y}&p6{ktJB87cz9yKBUX<~R?NI1Vn`pCsGY_k>`M+2g>_d5?Glel*7dv)( zs;N$&5l*TBCr;W6Df@c(>nc0Abk3?_9wH*Mxi94<78fXv0hnc zVf&tHs`s@Nt8XObzgtBE{#U~G5;06mIgL>?6C;nNT%NNNo*6@{yM??(TWA6z`js#l z=g1=W{D@c$9ZTP)<0$j&<7ozViP>yo*6Xa7)w5XH?SN}qcgRwWA)uGwO`v=VtU6Q3 zJCr%G0Z&bg?9e3IGQ-so<;ipkol0AxDV>K^)mWNFSt{9d%6Sx<%%EI$q|&3xw@c5Y zl|4KYKQ@bsB-vJ=vzu9%tw86{xirhk7hsQ&=Ftqfg88(8CCo0M+(Q-NzDu+2Rnzr; zR8EA#m@A3)Y$0WsQ4Z8alp{Ft*WcGSfC)g11m!ByXqLqWbHs?$)DB+4Fc}2dxt1 z9xWK>K;20bVSOKD7v=d{d05??RBX`^<=wQU(pMhz4=I~W&chl*vQ%#uyW*szF!oT6 zkXjFarClR~r8d#4iHW9~^7hhzw-=Qpek!EgNBKDBanx7yea#!aJgkrCKx2@>0g6al z_~Wca<%4upVj<#WRC=o$mA^=#!~K+FI>(}JB0R1Msis-T2dDr@4pN>_afou#8F^TT zY3loQ!ZSzyNQB`+`-Jk0ntK|XPs_tPN+bCcAwWA!1w&3@9OFGz8|04DMe;H=vyHVC z=tjGSD@DM6LhuP5^pjNBh2RwBL%-8$BWROPDMwlPjAnIL1~AW1Ave~5r_R!Ibgc5W zGA^Q;zVnEsN@4U?xEjR^c>0lom;jeRXum1@x6DYRwDVLjpA^RDDSP*Yc$NqK0%bQ6 zDZ%?icBaWD7x{szg5?d|-UdF%C7KGUF4M|4Os)0mb~|qcEana;}-sI)< zR&U#tjPCCeyh?PpL41}X+$*#hX_F>PtTuVjuTnOqsDpEjR&umr-TZ+4BA28XO$C~N zWgDbl{XJi)|D5ohOxJ0~Ch1#6#b;3pJ<&ud!2U7=TDZ_|Q2t}xq*-NM3gZ?P*TijF z-E2Eqn0F{&D|VoMMU$Y;CU+@evt-fsZrp^i$vv7dWhjjMRP6tHKr1ALUkQ5)jIbO1 zv|?c^&}bLCYBiV_%J|ReymD7REBRS~-ObO$>D~SGfED5P@Uw7zM0OM6?0Wb;{bn{K z%ERjAXYESc+h=^h^LV0?etJZQav!xgzKd_BqR5t0D+;jpDd~&8gs<;siM1`> ze!31$lb?a0^RQ+pDfUGJp6cT#8*y&YT5$Qkem}pzUnOOT+8v?+{zBZ$*&zdbhTxWm z^^4_ljiH;&sN+g{7ZVTqJc+F_7hn(c(}3Y|)DQBT#Wl&rUU+4&pQOe-=tF#7hkvNg z?HWJG7PrXrL5BG$wDPgMUrwdyJm@DZR+Z0%Hr#)M%W5aPs0lb{jL6pQqlvkvSsssk z-t;S&A~BsKeCBaFqCC>CkenYaJQ0<_e=qL8kON-G%FU1Rn^E;yrSK-ugZ_!j0>#h` z_(G*(wkbAGlQBM14r_zlB6YFa3|R7@zvT;9bdok%+*^>s80&Kt-DuysY{OFz|ANsB z%f}{f`<%4KxD3k}z#Q+d*RRTa!^1*7)toYc{bVnrVSv-3t>rV&(L26?aYCr#H&e!& zd)S97^8o6PXJ((D{FmClH%P$GbjlKQRrDL>cCa-v1J>ndqe0jJjx-W6F;Zgil| z^f@utZKm*+A+n1I;Oy_7MTc4bR%5pRXt*7~oa3iA2A%7RxH5pb+fA!gS9VkCEYi>C z`RoL;Q;wO!*s6(DzY)@9?Y4S>E$MD*X4RG8&G&a@ZQ~x|Utb)M0_=A)mR_vD!fOqB z&=>k_mBa_BkS;htw8-Z}s}j5=UP9#OHMbZ0VkvEj|DMk^Oup}njw@oZ)n*bmSdp}3 zmI7L=^v6fsF0`dS^Yl-m%`$(v-vT>-NXe4mTH&Xhdja-Je?=-06=F&)J5;KJv&zp% zX+`uQPG!#_t9|mR&Qt)_FfgSh#kY7IC~SP##zr9;=auk0 z=<9tUcxy_F@jFmA_}rS<=<_p=!r0_@5tx3PeNi4D#21=SY>YQ866x++{Hz7m4u!4$ z2mUsHyT8M)u?8MvA8n_fg5bOS)Ou`e4z_%xBmtwY|&5QY>^N0p`R*9P9kD3 zw1)?0yN&s3XOXP1P*3S?P#6bnafi9htmKOeZ4w)tc0_rv-%=5b91uplyrrNpPC(?p z-n1B|fkXDOpe9zB;1{(A{Ucv6pdb5t#LaE?`#ceE4SkU_BL|v;XG3g$rj+2F*E21y z5Ar_YUopf7{zF-hhqBgw9@b$$RW)maT=@DiYf%gX+8}qt-z)FSR&eNqXO=0S_(%O? z{&D|=f70LVp7P()$LeqE+!gij4a6_>(>8p@HLY)7i{P>Owo`^Lhp6{Y+8w&;6c8FN4A7v7``%@dXd` zDU6+_c;tfrkL>=0C3qM8%u#otUGn*z3@-~Xp$PYipA{>3(D#aZO&_@_?>>MYl6WR~ z4!G)zS976V^Z82HkGs5b-RIKwHu=)$O1E*ZH`m8>!xyG#0P{3SH(B4z{6-YUE&nwE z)86(u3d#tVGln5%@Uv0h(`Z*o-!z*5P8bS?`o5p;FAiW{P%o<81AZ3&K*;`47(D_pq0uv7OWO|AUd)p}k0*KuRl?Mx z`@tR36fhppA${0?7GOtVyO2i)=^HSo%%SP8QH0wsXvUcI4|pUtAmBD|-(y53T`aK- z3{t?UQR*rU3YyV3ccBdqiY2zI_rBP1LSYOEhN`^U|ImP&8F^Uy%m*TX3{j4m#XDI~ zQXQvtaQcx9FI#C zSRiHpF(UY1=NKU2?T9E3UCprZnR^%71?NZH>VG?!rSmeXIp9lMl=SmK#sz#vqXh4u zlo_si(0?YvlqKqWDoby#%GCWcOXhW;jt@AjFd>M3lvLe!8+@#thqYEpfpQP}JAoil zIWb_LK7ctX5Qc}sm>h^aw*&0H4KB1PLTz$tuvx2W{yo|%)nvvd2o09gf>d`Q!*mxb zj1w~itee{iTy#zK@TUjO$dyqTGXl;|KZ_1CgO>5Ve2`f|st{OBOud$(4@B(f?%e(^ z2f(kISOzLpe>y?3WQ5|zIxY*JhjrG?Lel4@fBZGSQ`=Na6hxdIaEqWdg4JqsD>H!k zsVtNUU1)Ox-o?!g*sM(v?!2HGBI*1<=3>a73CaEi0l%Z)yX?P^#A?SfcN zIOxS&1&YCKkV>)@VDHz`a_ES3ft8s5tSu}8cBdwGz_A)H6DVPya_blKemS+W%Yn?SM2 z%7AN-S`{Sjxg83tgEhh0V6nNx+)6$mAFAS_Ul$0+zJ|wZdOqVZ0S_-lQW)z4(ZEz& z2RUOxVQdJ*yHD5BXm~3G(*&|HV1`zUc01V=Y_1)A5^Y#+WOJsZ0DDW27Vf1mwgzHW z?1Ml6#%q;~EsP4V*VyUOUIBI`*PB9R+X6lTOkr#fIQdEs`i?;0V(tw1M(dstNi}uP zNIM}-|JxPtd65F_-N7kIM5!M14+A#MO=0W_xP{vm`HH|mb7iHy0avcr7x3imFmw#~ z9Yk#MG4mT2+WvrNx*rJmR9-ivvXgmO2ZJrTAlzPneJEqSdC(7YE4U4E7bv?Qya
YPtV{nXNXt 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

=qRe{uaE^DJa$9iXcRQjkI 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

^TWW$$XToj+V6u0tX46llgP9TB?mf&KWDg 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 literal 0 HcmV?d00001 diff --git a/docs/run-books.md b/docs/run-books.md new file mode 100644 index 0000000..61901c7 --- /dev/null +++ b/docs/run-books.md @@ -0,0 +1,13 @@ +# Release process + +1. update usage in README.md and gdu.1.md +1. `make show-man` +1. `make man` +1. commit the changes +1. tag new version with `-sa` +1. `make` +1. `git push --tags` +1. `git push` +1. `make release` +1. update `gdu.spec` +1. Release snapcraft, AUR, ... diff --git a/gdu.1 b/gdu.1 new file mode 100644 index 0000000..6238e46 --- /dev/null +++ b/gdu.1 @@ -0,0 +1,158 @@ +.\" Automatically generated by Pandoc 3.4 +.\" +.TH "gdu" "1" "2026\-03\-09" "" +.SH NAME +gdu \- Pretty fast disk usage analyzer written in Go +.SH SYNOPSIS +\f[B]gdu [flags] [directory_to_scan]\f[R] +.SH DESCRIPTION +Pretty fast disk usage analyzer written in Go. +.PP +Gdu is intended primarily for SSD disks where it can fully utilize +parallel processing. +However HDDs work as well, but the performance gain is not so huge. +.SH OPTIONS +\f[B]\-h\f[R], \f[B]\-\-help\f[R][=false] help for gdu +.PP +\f[B]\-i\f[R], \f[B]\-\-ignore\-dirs\f[R]=[/proc,/dev,/sys,/run] Paths +to ignore (separated by comma). +Supports both absolute and relative paths. +.PP +\f[B]\-I\f[R], \f[B]\-\-ignore\-dirs\-pattern\f[R] Path patterns to +ignore (separated by comma). +Supports both absolute and relative path patterns. +.PP +\f[B]\-X\f[R], \f[B]\-\-ignore\-from\f[R] Read path patterns to ignore +from file. +Supports both absolute and relative path patterns. +.PP +\f[B]\-T\f[R], \f[B]\-\-type\f[R] File types to include (e.g., \[en]type +yaml,json) +.PP +\f[B]\-E\f[R], \f[B]\-\-exclude\-type\f[R] File types to exclude (e.g., +\[en]exclude\-type yaml,json) +.PP +\f[B]\-\-max\-age\f[R] Include files with mtime no older than DURATION +(e.g., 7d, 2h30m, 1y2mo) +.PP +\f[B]\-\-min\-age\f[R] Include files with mtime at least DURATION old +(e.g., 30d, 1w) +.PP +\f[B]\-\-since\f[R] Include files with mtime >= WHEN. +WHEN accepts RFC3339 timestamp (e.g., 2025\-08\-11T01:00:00\-07:00) or +date only YYYY\-MM\-DD (calendar\-day compare; includes the whole day) +.PP +\f[B]\-\-until\f[R] Include files with mtime <= WHEN. +WHEN accepts RFC3339 timestamp or date only YYYY\-MM\-DD +.PP +\f[B]\-l\f[R], \f[B]\-\-log\-file\f[R]=\[dq]/dev/null\[dq] Path to a +logfile +.PP +\f[B]\-m\f[R], \f[B]\-\-max\-cores\f[R] Set max cores that Gdu will use. +.PP +\f[B]\-c\f[R], \f[B]\-\-no\-color\f[R][=false] Do not use colorized +output +.PP +\f[B]\-x\f[R], \f[B]\-\-no\-cross\f[R][=false] Do not cross filesystem +boundaries +.PP +\f[B]\-H\f[R], \f[B]\-\-no\-hidden\f[R][=false] Ignore hidden +directories (beginning with dot) +.PP +\f[B]\-L\f[R], \f[B]\-\-follow\-symlinks\f[R][=false] Follow symlinks +for files, i.e.\ show the size of the file to which symlink points to +(symlinks to directories are not followed) +.PP +\f[B]\-n\f[R], \f[B]\-\-non\-interactive\f[R][=false] Do not run in +interactive mode +.PP +\f[B]\-p\f[R], \f[B]\-\-no\-progress\f[R][=false] Do not show progress +in non\-interactive mode +.PP +\f[B]\-u\f[R], \f[B]\-\-no\-unicode\f[R][=false] Do not use Unicode +symbols (for size bar) +.PP +\f[B]\-s\f[R], \f[B]\-\-summarize\f[R][=false] Show only a total in +non\-interactive mode +.PP +\f[B]\-t\f[R], \f[B]\-\-top\f[R][=0] Show only top X largest files in +non\-interactive mode +.PP +\f[B]\-d\f[R], \f[B]\-\-show\-disks\f[R][=false] Show all mounted disks +.PP +\f[B]\-a\f[R], \f[B]\-\-show\-apparent\-size\f[R][=false] Show apparent +size +.PP +\f[B]\-C\f[R], \f[B]\-\-show\-item\-count\f[R][=false] Show number of +items in directory +.PP +\f[B]\-k\f[R], \f[B]\-\-show\-in\-kib\f[R][=false] Show sizes in KiB (or +kB with \[en]si) in non\-interactive mode +.PP +\f[B]\-M\f[R], \f[B]\-\-show\-mtime\f[R][=false] Show latest mtime of +items in directory +.PP +\f[B]\-\-archive\-browsing\f[R][=false] Enable browsing of zip/jar +archives +.PP +\f[B]\-\-depth\f[R][=0] Show directory structure up to specified depth +in non\-interactive mode (0 means the flag is ignored) +.PP +\f[B]\-\-collapse\-path\f[R][=false] Collapse single\-child directory +chains +.PP +\f[B]\-\-mouse\f[R][=false] Use mouse +.PP +\f[B]\-\-si\f[R][=false] Show sizes with decimal SI prefixes (kB, MB, +GB) instead of binary prefixes (KiB, MiB, GiB) +.PP +\f[B]\-\-no\-prefix\f[R][=false] Show sizes as raw numbers without any +prefixes (SI or binary) in non\-interactive mode +.PP +\f[B]\-\-no\-spawn\-shell\f[R][=false] Do not allow spawning shell +.PP +\f[B]\-\-no\-delete\f[R][=false] Do not allow deletions +.PP +\f[B]\-\-no\-view\-file\f[R][=false] Do not allow viewing file contents +.PP +\f[B]\-f\f[R], \f[B]\-\-input\-file\f[R] Import analysis from JSON file. +If the file is \[dq]\-\[dq], read from standard input. +.PP +\f[B]\-o\f[R], \f[B]\-\-output\-file\f[R] Export all info into file as +JSON. +If the file is \[dq]\-\[dq], write to standard output. +.PP +\f[B]\-\-config\-file\f[R]=\[dq]$HOME/.gdu.yaml\[dq] Read config from +file +.PP +\f[B]\-\-write\-config\f[R][=false] Write current configuration to file +(default is $HOME/.gdu.yaml) +.PP +\f[B]\-\-enable\-profiling\f[R][=false] Enable collection of profiling +data and provide it on http://localhost:6060/debug/pprof/ +.PP +\f[B]\-D\f[R], \f[B]\-\-db\f[R] Store analysis in database (\f[I].sqlite +for SQLite, \f[R].badger for BadgerDB) +.PP +\f[B]\-r\f[R], \f[B]\-\-read\-from\-storage\f[R][=false] Use existing +database instead of re\-scanning +.PP +\f[B]\-v\f[R], \f[B]\-\-version\f[R][=false] Print version +.SH FILE FLAGS +Files and directories may be prefixed by a one\-character flag with +following meaning: +.TP +\f[B]!\f[R] +An error occurred while reading this directory. +.TP +\f[B].\f[R] +An error occurred while reading a subdirectory, size may be not correct. +.TP +\f[B]\[at]\f[R] +File is symlink or socket. +.TP +\f[B]H\f[R] +Same file was already counted (hard link). +.TP +\f[B]e\f[R] +Directory is empty. diff --git a/gdu.1.md b/gdu.1.md new file mode 100644 index 0000000..1a0aff6 --- /dev/null +++ b/gdu.1.md @@ -0,0 +1,142 @@ +--- +date: {{date}} +section: 1 +title: gdu +--- + +# NAME + +gdu - Pretty fast disk usage analyzer written in Go + +# SYNOPSIS + +**gdu \[flags\] \[directory_to_scan\]** + +# DESCRIPTION + +Pretty fast disk usage analyzer written in Go. + +Gdu is intended primarily for SSD disks where it can fully utilize +parallel processing. However HDDs work as well, but the performance gain +is not so huge. + +# OPTIONS + +**-h**, **\--help**\[=false\] help for gdu + +**-i**, **\--ignore-dirs**=\[/proc,/dev,/sys,/run\] + Paths to ignore (separated by comma). + Supports both absolute and relative paths. + +**-I**, **\--ignore-dirs-pattern** + Path patterns to ignore (separated by comma). + Supports both absolute and relative path patterns. + +**-X**, **\--ignore-from** + Read path patterns to ignore from file. + Supports both absolute and relative path patterns. + +**-T**, **\--type** File types to include (e.g., --type yaml,json) + +**-E**, **\--exclude-type** File types to exclude (e.g., --exclude-type yaml,json) + +**\--max-age** Include files with mtime no older than DURATION (e.g., 7d, 2h30m, 1y2mo) + +**\--min-age** Include files with mtime at least DURATION old (e.g., 30d, 1w) + +**\--since** Include files with mtime >= WHEN. WHEN accepts RFC3339 timestamp (e.g., 2025-08-11T01:00:00-07:00) or date only YYYY-MM-DD (calendar-day compare; includes the whole day) + +**\--until** Include files with mtime <= WHEN. WHEN accepts RFC3339 timestamp or date only YYYY-MM-DD + +**-l**, **\--log-file**=\"/dev/null\" Path to a logfile + +**-m**, **\--max-cores** Set max cores that Gdu will use. + +**-c**, **\--no-color**\[=false\] Do not use colorized output + +**-x**, **\--no-cross**\[=false\] Do not cross filesystem boundaries + +**-H**, **\--no-hidden**\[=false\] Ignore hidden directories (beginning with dot) + +**-L**, **\--follow-symlinks**\[=false\] Follow symlinks for files, i.e. show the +size of the file to which symlink points to (symlinks to directories are not followed) + +**-n**, **\--non-interactive**\[=false\] Do not run in interactive mode + +**-p**, **\--no-progress**\[=false\] Do not show progress in +non-interactive mode + +**-u**, **\--no-unicode**\[=false\] Do not use Unicode symbols (for size bar) + +**-s**, **\--summarize**\[=false\] Show only a total in non-interactive mode + +**-t**, **\--top**\[=0\] Show only top X largest files in non-interactive mode + +**-d**, **\--show-disks**\[=false\] Show all mounted disks + +**-a**, **\--show-apparent-size**\[=false\] Show apparent size + +**-C**, **\--show-item-count**\[=false\] Show number of items in directory + +**-k**, **\--show-in-kib**\[=false\] Show sizes in KiB (or kB with --si) in non-interactive mode + +**-M**, **\--show-mtime**\[=false\] Show latest mtime of items in directory + +**\--archive-browsing**\[=false\] Enable browsing of zip/jar archives + +**\--depth**\[=0\] Show directory structure up to specified depth in non-interactive mode (0 means the flag is ignored) + +**\--collapse-path**\[=false\] Collapse single-child directory chains + +**\--mouse**\[=false\] Use mouse + +**\--si**\[=false\] Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) + +**\--no-prefix**\[=false\] Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode + +**\--no-spawn-shell**\[=false\] Do not allow spawning shell + +**\--no-delete**\[=false\] Do not allow deletions + +**\--no-view-file**\[=false\] Do not allow viewing file contents + +**-f**, **\--input-file** Import analysis from JSON file. If the file is \"-\", read from standard input. + +**-o**, **\--output-file** Export all info into file as JSON. If the file is \"-\", write to standard output. + +**\--config-file**=\"$HOME/.gdu.yaml\" Read config from file + +**\--write-config**\[=false\] Write current configuration to file (default is $HOME/.gdu.yaml) + +**\--enable-profiling**\[=false\] Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ + +**-D**, **\--db** Store analysis in database (*.sqlite for SQLite, *.badger for BadgerDB) + +**-r**, **\--read-from-storage**\[=false\] Use existing database instead of re-scanning + +**-v**, **\--version**\[=false\] Print version + +# FILE FLAGS + +Files and directories may be prefixed by a one-character +flag with following meaning: + +**!** + +: An error occurred while reading this directory. + +**.** + +: An error occurred while reading a subdirectory, size may be not correct. + +**\@** + +: File is symlink or socket. + +**H** + +: Same file was already counted (hard link). + +**e** + +: Directory is empty. diff --git a/gdu.png b/gdu.png new file mode 100644 index 0000000000000000000000000000000000000000..e2716d9960963915381458a744f4ebdcf433d797 GIT binary patch literal 131657 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 63) == 1 diff --git a/internal/common/ignore.go b/internal/common/ignore.go new file mode 100644 index 0000000..359ee49 --- /dev/null +++ b/internal/common/ignore.go @@ -0,0 +1,235 @@ +// Package common contains commong logic and interfaces used across Gdu +// nolint: revive //Why: this is common package +package common + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "strings" + + log "github.com/sirupsen/logrus" +) + +// CreateIgnorePattern creates one pattern from all path patterns +func CreateIgnorePattern(paths []string) (compiled *regexp.Regexp, err error) { + for i, path := range paths { + if _, err = regexp.Compile(path); err != nil { + return nil, err + } + if !filepath.IsAbs(path) { + absPath, err := filepath.Abs(path) + if err == nil { + paths = append(paths, absPath) + } + } else { + relPath, err := filepath.Rel("/", path) + if err == nil { + paths = append(paths, relPath) + } + } + paths[i] = "(" + path + ")" + } + + ignore := `^` + strings.Join(paths, "|") + `$` + return regexp.Compile(ignore) +} + +// SetIgnoreDirPaths sets paths to ignore +func (ui *UI) SetIgnoreDirPaths(paths []string) { + log.Printf("Ignoring dirs %s", strings.Join(paths, ", ")) + ui.IgnoreDirPaths = make(map[string]struct{}, len(paths)*2) + for _, path := range paths { + ui.IgnoreDirPaths[path] = struct{}{} + if !filepath.IsAbs(path) { + if absPath, err := filepath.Abs(path); err == nil { + ui.IgnoreDirPaths[absPath] = struct{}{} + } + } else { + if relPath, err := filepath.Rel("/", path); err == nil { + ui.IgnoreDirPaths[relPath] = struct{}{} + } + } + } +} + +// SetIgnoreDirPatterns sets regular patterns of dirs to ignore +func (ui *UI) SetIgnoreDirPatterns(paths []string) error { + var err error + log.Printf("Ignoring dir patterns %s", strings.Join(paths, ", ")) + ui.IgnoreDirPathPatterns, err = CreateIgnorePattern(paths) + return err +} + +// SetIgnoreFromFile sets regular patterns of dirs to ignore +func (ui *UI) SetIgnoreFromFile(ignoreFile string) error { + var err error + var paths []string + log.Printf("Reading ignoring dir patterns from file '%s'", ignoreFile) + + file, err := os.Open(ignoreFile) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + paths = append(paths, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return err + } + + ui.IgnoreDirPathPatterns, err = CreateIgnorePattern(paths) + return err +} + +// SetIgnoreTypes sets file types to ignore +func (ui *UI) SetIgnoreTypes(types []string) { + log.Printf("Ignoring file types: %s", strings.Join(types, ", ")) + ui.IgnoreTypes = types +} + +// SetIncludeTypes sets file types to include (whitelist) +func (ui *UI) SetIncludeTypes(types []string) { + log.Printf("Including only file types: %s", strings.Join(types, ", ")) + ui.IncludeTypes = types +} + +// SetIgnoreHidden sets flags if hidden dirs should be ignored +func (ui *UI) SetIgnoreHidden(value bool) { + log.Printf("Ignoring hidden dirs") + ui.IgnoreHidden = value +} + +// ShouldDirBeIgnored returns true if given path should be ignored +func (ui *UI) ShouldDirBeIgnored(name, path string) bool { + _, shouldIgnore := ui.IgnoreDirPaths[path] + if shouldIgnore { + log.Printf("Directory %s ignored", path) + } + return shouldIgnore +} + +// ShouldDirBeIgnoredUsingPattern returns true if given path should be ignored +func (ui *UI) ShouldDirBeIgnoredUsingPattern(name, path string) bool { + shouldIgnore := ui.IgnoreDirPathPatterns.MatchString(path) + if shouldIgnore { + log.Printf("Directory %s ignored", path) + } + return shouldIgnore +} + +// IsHiddenDir returns if the dir name begins with dot +func (ui *UI) IsHiddenDir(name, path string) bool { + shouldIgnore := name[0] == '.' + if shouldIgnore { + log.Printf("Directory %s ignored", path) + } + return shouldIgnore +} + +// ShouldFileBeIgnoredByType returns true if file should be ignored based on its extension +func (ui *UI) ShouldFileBeIgnoredByType(name string) bool { + if len(ui.IgnoreTypes) == 0 { + return false + } + + ext := strings.ToLower(filepath.Ext(name)) + if ext == "" { + return false // No extension, don't ignore + } + + // Remove leading dot from extension + ext = strings.TrimPrefix(ext, ".") + + for _, ignoreType := range ui.IgnoreTypes { + // Remove leading dot from ignoreType + cleanIgnoreType := strings.TrimPrefix(strings.ToLower(ignoreType), ".") + if cleanIgnoreType == ext { + log.Printf("File %s ignored by type", name) + return true + } + } + return false +} + +// ShouldFileBeIncludedByType returns true if file should be included based on its extension +func (ui *UI) ShouldFileBeIncludedByType(name string) bool { + if len(ui.IncludeTypes) == 0 { + return true // No include filter, include all + } + + ext := strings.ToLower(filepath.Ext(name)) + if ext == "" { + return false // No extension, don't include if we have include filter + } + + // Remove leading dot from extension + ext = strings.TrimPrefix(ext, ".") + + for _, includeType := range ui.IncludeTypes { + // Remove leading dot from includeType + cleanIncludeType := strings.TrimPrefix(strings.ToLower(includeType), ".") + if cleanIncludeType == ext { + return true + } + } + + log.Printf("File %s excluded by type filter", name) + return false +} + +// CreateIgnoreFunc returns function for detecting if dir should be ignored +// nolint: gocyclo // Why: This function is a switch statement that is not too complex +func (ui *UI) CreateIgnoreFunc() ShouldDirBeIgnored { + switch { + case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns == nil && !ui.IgnoreHidden: + return ui.ShouldDirBeIgnored + case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns != nil && !ui.IgnoreHidden: + return func(name, path string) bool { + return ui.ShouldDirBeIgnored(name, path) || ui.ShouldDirBeIgnoredUsingPattern(name, path) + } + case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns != nil && ui.IgnoreHidden: + return func(name, path string) bool { + return ui.ShouldDirBeIgnored(name, path) || ui.ShouldDirBeIgnoredUsingPattern(name, path) || ui.IsHiddenDir(name, path) + } + case len(ui.IgnoreDirPaths) == 0 && ui.IgnoreDirPathPatterns != nil && ui.IgnoreHidden: + return func(name, path string) bool { + return ui.ShouldDirBeIgnoredUsingPattern(name, path) || ui.IsHiddenDir(name, path) + } + case len(ui.IgnoreDirPaths) == 0 && ui.IgnoreDirPathPatterns != nil && !ui.IgnoreHidden: + return ui.ShouldDirBeIgnoredUsingPattern + case len(ui.IgnoreDirPaths) == 0 && ui.IgnoreDirPathPatterns == nil && ui.IgnoreHidden: + return ui.IsHiddenDir + case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns == nil && ui.IgnoreHidden: + return func(name, path string) bool { + return ui.ShouldDirBeIgnored(name, path) || ui.IsHiddenDir(name, path) + } + default: + return func(name, path string) bool { return false } + } +} + +// CreateFileTypeFilter returns function for detecting if file should be ignored based on type +func (ui *UI) CreateFileTypeFilter() ShouldFileBeIgnored { + // If we have include types, use whitelist mode + if len(ui.IncludeTypes) > 0 { + return func(name string) bool { + return !ui.ShouldFileBeIncludedByType(name) + } + } + + // If we have ignore types, use blacklist mode + if len(ui.IgnoreTypes) > 0 { + return func(name string) bool { + return ui.ShouldFileBeIgnoredByType(name) + } + } + + // No type filtering - return nil to indicate no filtering is needed + return nil +} diff --git a/internal/common/ignore_test.go b/internal/common/ignore_test.go new file mode 100644 index 0000000..d50ff54 --- /dev/null +++ b/internal/common/ignore_test.go @@ -0,0 +1,470 @@ +package common_test + +import ( + "os" + "path/filepath" + "testing" + + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestCreateIgnorePattern(t *testing.T) { + re, err := common.CreateIgnorePattern([]string{"[abc]+"}) + + assert.Nil(t, err) + assert.True(t, re.MatchString("aa")) +} + +func TestCreateIgnorePatternWithErr(t *testing.T) { + re, err := common.CreateIgnorePattern([]string{"[[["}) + + assert.NotNil(t, err) + assert.Nil(t, re) +} + +func TestEmptyIgnore(t *testing.T) { + ui := &common.UI{} + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.False(t, shouldBeIgnored("abc", "/abc")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByAbsPath(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreDirPaths([]string{"/abc"}) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "/abc")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByPattern(t *testing.T) { + ui := &common.UI{} + err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) + assert.Nil(t, err) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("aaa", "/aaa")) + assert.True(t, shouldBeIgnored("aaa", "/aaabc")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreFromFile(t *testing.T) { + file, err := os.OpenFile("ignore", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + panic(err) + } + defer file.Close() + + if _, err := file.WriteString("/aaa\n"); err != nil { + panic(err) + } + if _, err := file.WriteString("/aaabc\n"); err != nil { + panic(err) + } + if _, err := file.WriteString("/[abd]+\n"); err != nil { + panic(err) + } + + ui := &common.UI{} + err = ui.SetIgnoreFromFile("ignore") + assert.Nil(t, err) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("aaa", "/aaa")) + assert.True(t, shouldBeIgnored("aaabc", "/aaabc")) + assert.True(t, shouldBeIgnored("aaabd", "/aaabd")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreFromNotExistingFile(t *testing.T) { + ui := &common.UI{} + err := ui.SetIgnoreFromFile("xxx") + assert.NotNil(t, err) +} + +func TestIgnoreHidden(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreHidden(true) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) + assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByAbsPathAndHidden(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreDirPaths([]string{"/abc"}) + ui.SetIgnoreHidden(true) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "/abc")) + assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) + assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByAbsPathAndPattern(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreDirPaths([]string{"/abc"}) + err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) + assert.Nil(t, err) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "/abc")) + assert.True(t, shouldBeIgnored("aabc", "/aabc")) + assert.True(t, shouldBeIgnored("ccc", "/ccc")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByPatternAndHidden(t *testing.T) { + ui := &common.UI{} + err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) + assert.Nil(t, err) + ui.SetIgnoreHidden(true) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abbc", "/abbc")) + assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) + assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByAll(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreDirPaths([]string{"/abc"}) + err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) + assert.Nil(t, err) + ui.SetIgnoreHidden(true) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "/abc")) + assert.True(t, shouldBeIgnored("aabc", "/aabc")) + assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) + assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) + assert.False(t, shouldBeIgnored("xxx", "/xxx")) +} + +func TestIgnoreByRelativePath(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreDirPaths([]string{"test_dir/abc"}) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "test_dir/abc")) + absPath, err := filepath.Abs("test_dir/abc") + assert.Nil(t, err) + assert.True(t, shouldBeIgnored("abc", absPath)) + assert.False(t, shouldBeIgnored("xxx", "test_dir/xxx")) +} + +func TestIgnoreByRelativePattern(t *testing.T) { + ui := &common.UI{} + err := ui.SetIgnoreDirPatterns([]string{"test_dir/[abc]+"}) + assert.Nil(t, err) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("abc", "test_dir/abc")) + absPath, err := filepath.Abs("test_dir/abc") + assert.Nil(t, err) + assert.True(t, shouldBeIgnored("abc", absPath)) + assert.False(t, shouldBeIgnored("xxx", "test_dir/xxx")) +} + +func TestIgnoreFromFileWithRelativePaths(t *testing.T) { + file, err := os.OpenFile("ignore", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + panic(err) + } + defer file.Close() + defer os.Remove("ignore") + + if _, err := file.WriteString("test_dir/aaa\n"); err != nil { + panic(err) + } + if _, err := file.WriteString("node_modules/[^/]+\n"); err != nil { + panic(err) + } + + ui := &common.UI{} + err = ui.SetIgnoreFromFile("ignore") + assert.Nil(t, err) + shouldBeIgnored := ui.CreateIgnoreFunc() + + assert.True(t, shouldBeIgnored("aaa", "test_dir/aaa")) + absPath, err := filepath.Abs("test_dir/aaa") + assert.Nil(t, err) + assert.True(t, shouldBeIgnored("aaa", absPath)) + assert.False(t, shouldBeIgnored("xxx", "test_dir/xxx")) +} + +func TestShouldFileBeIgnoredByType(t *testing.T) { + tests := []struct { + name string + ignoreTypes []string + filename string + expectedIgnored bool + }{ + { + name: "no ignore types", + ignoreTypes: []string{}, + filename: "test.yaml", + expectedIgnored: false, + }, + { + name: "ignore yaml", + ignoreTypes: []string{"yaml"}, + filename: "test.yaml", + expectedIgnored: true, + }, + { + name: "ignore json", + ignoreTypes: []string{"json"}, + filename: "test.json", + expectedIgnored: true, + }, + { + name: "ignore multiple types", + ignoreTypes: []string{"yaml", "json"}, + filename: "test.yaml", + expectedIgnored: true, + }, + { + name: "ignore multiple types - not matched", + ignoreTypes: []string{"yaml", "json"}, + filename: "test.txt", + expectedIgnored: false, + }, + + { + name: "ignore with uppercase", + ignoreTypes: []string{"YAML"}, + filename: "test.yaml", + expectedIgnored: true, + }, + { + name: "ignore file without extension", + ignoreTypes: []string{"yaml"}, + filename: "test", + expectedIgnored: false, + }, + { + name: "ignore with dot in extension", + ignoreTypes: []string{".yaml"}, + filename: "test.yaml", + expectedIgnored: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ui := &common.UI{} + ui.SetIgnoreTypes(tt.ignoreTypes) + + actual := ui.ShouldFileBeIgnoredByType(tt.filename) + assert.Equal(t, tt.expectedIgnored, actual) + }) + } +} + +func TestShouldFileBeIncludedByType(t *testing.T) { + tests := []struct { + name string + includeTypes []string + filename string + expectedIncluded bool + }{ + { + name: "no include types", + includeTypes: []string{}, + filename: "test.yaml", + expectedIncluded: true, + }, + { + name: "include yaml", + includeTypes: []string{"yaml"}, + filename: "test.yaml", + expectedIncluded: true, + }, + { + name: "include json", + includeTypes: []string{"json"}, + filename: "test.json", + expectedIncluded: true, + }, + { + name: "include multiple types", + includeTypes: []string{"yaml", "json"}, + filename: "test.yaml", + expectedIncluded: true, + }, + { + name: "include multiple types - not matched", + includeTypes: []string{"yaml", "json"}, + filename: "test.txt", + expectedIncluded: false, + }, + + { + name: "include with uppercase", + includeTypes: []string{"YAML"}, + filename: "test.yaml", + expectedIncluded: true, + }, + { + name: "include file without extension", + includeTypes: []string{"yaml"}, + filename: "test", + expectedIncluded: false, + }, + { + name: "include with dot in extension", + includeTypes: []string{".yaml"}, + filename: "test.yaml", + expectedIncluded: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ui := &common.UI{} + ui.SetIncludeTypes(tt.includeTypes) + + actual := ui.ShouldFileBeIncludedByType(tt.filename) + assert.Equal(t, tt.expectedIncluded, actual) + }) + } +} + +func TestCreateFileTypeFilter(t *testing.T) { + tests := []struct { + name string + includeTypes []string + ignoreTypes []string + filename string + expectedFiltered bool + }{ + { + name: "no filters", + includeTypes: []string{}, + ignoreTypes: []string{}, + filename: "test.yaml", + expectedFiltered: false, + }, + { + name: "include filter - matched", + includeTypes: []string{"yaml"}, + ignoreTypes: []string{}, + filename: "test.yaml", + expectedFiltered: false, + }, + { + name: "include filter - not matched", + includeTypes: []string{"json"}, + ignoreTypes: []string{}, + filename: "test.yaml", + expectedFiltered: true, + }, + { + name: "ignore filter - matched", + includeTypes: []string{}, + ignoreTypes: []string{"yaml"}, + filename: "test.yaml", + expectedFiltered: true, + }, + { + name: "ignore filter - not matched", + includeTypes: []string{}, + ignoreTypes: []string{"json"}, + filename: "test.yaml", + expectedFiltered: false, + }, + { + name: "include filter takes precedence", + includeTypes: []string{"yaml"}, + ignoreTypes: []string{"yaml"}, + filename: "test.yaml", + expectedFiltered: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ui := &common.UI{} + ui.SetIncludeTypes(tt.includeTypes) + ui.SetIgnoreTypes(tt.ignoreTypes) + + filter := ui.CreateFileTypeFilter() + var actual bool + if filter == nil { + // When filter is nil, no filtering is applied, so file should not be filtered + actual = false + } else { + actual = filter(tt.filename) + } + assert.Equal(t, tt.expectedFiltered, actual) + }) + } +} + +func TestFileTypeFilterWithRealFiles(t *testing.T) { + // Create a temporary directory with test files + tmpDir := t.TempDir() + + // Create test files + testFiles := []struct { + name string + content string + expected bool // expected to be included + }{ + {"test.yaml", "key: value", true}, + {"test.json", "{\"key\": \"value\"}", true}, + {"test.txt", "plain text", false}, + {"test.go", "package main", false}, + {"noextension", "no extension", false}, + } + + for _, tf := range testFiles { + filePath := filepath.Join(tmpDir, tf.name) + err := os.WriteFile(filePath, []byte(tf.content), 0644) + assert.NoError(t, err) + } + + // Test include filter + ui := &common.UI{} + ui.SetIncludeTypes([]string{"yaml", "json"}) + filter := ui.CreateFileTypeFilter() + + for _, tf := range testFiles { + actual := filter(tf.name) + expected := !tf.expected // filter returns true if file should be filtered out + assert.Equal(t, expected, actual, "Failed for file: %s", tf.name) + } + + // Test ignore filter + ui2 := &common.UI{} + ui2.SetIgnoreTypes([]string{"txt", "go"}) + filter2 := ui2.CreateFileTypeFilter() + + for _, tf := range testFiles { + actual := filter2(tf.name) + // For ignore filter, yaml and json should not be filtered, txt and go should be filtered + expected := tf.name == "test.txt" || tf.name == "test.go" + assert.Equal(t, expected, actual, "Failed for file: %s", tf.name) + } +} + +func TestCreateFileTypeFilterReturnsNilWhenNoFiltering(t *testing.T) { + ui := &common.UI{} + // No include or ignore types set + filter := ui.CreateFileTypeFilter() + assert.Nil(t, filter, "CreateFileTypeFilter should return nil when no filtering is configured") +} diff --git a/internal/common/signal.go b/internal/common/signal.go new file mode 100644 index 0000000..ff04256 --- /dev/null +++ b/internal/common/signal.go @@ -0,0 +1,13 @@ +// Package common contains commong logic and interfaces used across Gdu +// nolint: revive //Why: this is common package +package common + +type SignalGroup chan struct{} + +func (s SignalGroup) Wait() { + <-s +} + +func (s SignalGroup) Broadcast() { + close(s) +} diff --git a/internal/common/ui.go b/internal/common/ui.go new file mode 100644 index 0000000..0c5d43e --- /dev/null +++ b/internal/common/ui.go @@ -0,0 +1,87 @@ +// Package common contains commong logic and interfaces used across Gdu +// nolint: revive //Why: this is common package +package common + +import ( + "regexp" + "strconv" +) + +// UI struct +type UI struct { + Analyzer Analyzer + IgnoreDirPaths map[string]struct{} + IgnoreDirPathPatterns *regexp.Regexp + IgnoreHidden bool + IgnoreTypes []string + IncludeTypes []string + UseColors bool + UseSIPrefix bool + ShowProgress bool + ShowApparentSize bool + ShowRelativeSize bool +} + +// SetAnalyzer sets analyzer instance +func (ui *UI) SetAnalyzer(a Analyzer) { + ui.Analyzer = a +} + +// SetFollowSymlinks sets whether symlinks to files should be followed +func (ui *UI) SetFollowSymlinks(v bool) { + ui.Analyzer.SetFollowSymlinks(v) +} + +// SetShowAnnexedSize sets whether to use annexed size of git-annex files +func (ui *UI) SetShowAnnexedSize(v bool) { + ui.Analyzer.SetShowAnnexedSize(v) +} + +// SetTimeFilter sets the time filter function for file inclusion +func (ui *UI) SetTimeFilter(timeFilter TimeFilter) { + ui.Analyzer.SetTimeFilter(timeFilter) +} + +// SetArchiveBrowsing sets whether browsing of zip/jar archives is enabled +func (ui *UI) SetArchiveBrowsing(v bool) { + ui.Analyzer.SetArchiveBrowsing(v) +} + +// binary multiplies prefixes (IEC) +const ( + _ float64 = 1 << (10 * iota) + Ki + Mi + Gi + Ti + Pi + Ei +) + +// SI prefixes +const ( + K float64 = 1e3 + M float64 = 1e6 + G float64 = 1e9 + T float64 = 1e12 + P float64 = 1e15 + E float64 = 1e18 +) + +// FormatNumber returns number as a string with thousands separator +func FormatNumber(n int64) string { + in := []byte(strconv.FormatInt(n, 10)) + + var out []byte + if i := len(in) % 3; i != 0 { + if out, in = append(out, in[:i]...), in[i:]; len(in) > 0 { + out = append(out, ',') + } + } + for len(in) > 0 { + if out, in = append(out, in[:3]...), in[3:]; len(in) > 0 { + out = append(out, ',') + } + } + return string(out) +} diff --git a/internal/common/ui_test.go b/internal/common/ui_test.go new file mode 100644 index 0000000..43919ff --- /dev/null +++ b/internal/common/ui_test.go @@ -0,0 +1,93 @@ +// Package common contains commong logic and interfaces used across Gdu +// nolint: revive //Why: this is common package +package common + +import ( + "testing" + + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestFormatNumber(t *testing.T) { + res := FormatNumber(1234567890) + assert.Equal(t, "1,234,567,890", res) +} + +func TestSetFollowSymlinks(t *testing.T) { + ui := UI{ + Analyzer: &MockedAnalyzer{}, + } + ui.SetFollowSymlinks(true) + + assert.Equal(t, true, ui.Analyzer.(*MockedAnalyzer).FollowSymlinks) +} + +func TestSetShowAnnexedSize(t *testing.T) { + ui := UI{ + Analyzer: &MockedAnalyzer{}, + } + ui.SetShowAnnexedSize(true) + + assert.Equal(t, true, ui.Analyzer.(*MockedAnalyzer).ShowAnnexedSize) +} + +func TestSetEnableArchiveBrowsing(t *testing.T) { + ui := UI{ + Analyzer: &MockedAnalyzer{}, + } + ui.SetArchiveBrowsing(true) + + assert.Equal(t, true, ui.Analyzer.(*MockedAnalyzer).ArchiveBrowsing) +} + +type MockedAnalyzer struct { + FollowSymlinks bool + ShowAnnexedSize bool + ArchiveBrowsing bool +} + +// SetFileTypeFilter sets the file type filter function +func (a *MockedAnalyzer) SetFileTypeFilter(filter ShouldFileBeIgnored) { + // Mock implementation - do nothing +} + +// AnalyzeDir returns dir with files with different size exponents +func (a *MockedAnalyzer) AnalyzeDir( + path string, ignore ShouldDirBeIgnored, fileTypeFilter ShouldFileBeIgnored, +) fs.Item { + return nil +} + +// GetProgressChan returns always Done +func (a *MockedAnalyzer) GetProgressChan() chan CurrentProgress { + return make(chan CurrentProgress) +} + +// GetDone returns always Done +func (a *MockedAnalyzer) GetDone() SignalGroup { + c := make(SignalGroup) + defer c.Broadcast() + return c +} + +// ResetProgress does nothing +func (a *MockedAnalyzer) ResetProgress() {} + +// SetFollowSymlinks does nothing +func (a *MockedAnalyzer) SetFollowSymlinks(v bool) { + a.FollowSymlinks = v +} + +// SetShowAnnexedSize does nothing +func (a *MockedAnalyzer) SetShowAnnexedSize(v bool) { + a.ShowAnnexedSize = v +} + +// SetTimeFilter does nothing +func (a *MockedAnalyzer) SetTimeFilter(timeFilter TimeFilter) {} + +// SetArchiveBrowsing sets EnableArchiveBrowsing +func (a *MockedAnalyzer) SetArchiveBrowsing(v bool) { + a.ArchiveBrowsing = v +} diff --git a/internal/testanalyze/analyze.go b/internal/testanalyze/analyze.go new file mode 100644 index 0000000..e475cc1 --- /dev/null +++ b/internal/testanalyze/analyze.go @@ -0,0 +1,114 @@ +package testanalyze + +import ( + "errors" + "time" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/dundee/gdu/v5/pkg/remove" +) + +// MockedAnalyzer returns dir with files with different size exponents +type MockedAnalyzer struct{} + +// AnalyzeDir returns dir with files with different size exponents +func (a *MockedAnalyzer) AnalyzeDir( + path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, +) fs.Item { + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + Usage: 1e12 + 1, + Size: 1e12 + 2, + Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), + }, + BasePath: ".", + ItemCount: 12, + } + dir2 := &analyze.Dir{ + File: &analyze.File{ + Name: "aaa", + Usage: 1e12 + 1, + Size: 1e12 + 2, + Mtime: time.Date(2021, 8, 27, 22, 23, 27, 0, time.UTC), + Parent: dir, + }, + } + dir3 := &analyze.Dir{ + File: &analyze.File{ + Name: "bbb", + Usage: 1e9 + 1, + Size: 1e9 + 2, + Mtime: time.Date(2021, 8, 27, 22, 23, 26, 0, time.UTC), + Parent: dir, + }, + } + dir4 := &analyze.Dir{ + File: &analyze.File{ + Name: "ccc", + Usage: 1e6 + 1, + Size: 1e6 + 2, + Mtime: time.Date(2021, 8, 27, 22, 23, 25, 0, time.UTC), + Parent: dir, + }, + } + file := &analyze.File{ + Name: "ddd", + Usage: 1e3 + 1, + Size: 1e3 + 2, + Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), + Parent: dir, + } + dir.Files = fs.Files{dir2, dir3, dir4, file} + + return dir +} + +// GetProgressChan returns always Done +func (a *MockedAnalyzer) GetProgressChan() chan common.CurrentProgress { + return make(chan common.CurrentProgress) +} + +// GetDone returns always Done +func (a *MockedAnalyzer) GetDone() common.SignalGroup { + c := make(common.SignalGroup) + defer c.Broadcast() + return c +} + +// ResetProgress does nothing +func (a *MockedAnalyzer) ResetProgress() {} + +// SetFollowSymlinks does nothing +func (a *MockedAnalyzer) SetFollowSymlinks(v bool) {} + +// SetShowAnnexedSize does nothing +func (a *MockedAnalyzer) SetShowAnnexedSize(v bool) {} + +// SetTimeFilter does nothing +func (a *MockedAnalyzer) SetTimeFilter(timeFilter common.TimeFilter) {} + +// SetArchiveBrowsing does nothing +func (a *MockedAnalyzer) SetArchiveBrowsing(v bool) {} + +// SetFileTypeFilter does nothing +func (a *MockedAnalyzer) SetFileTypeFilter(fileTypeFilter common.ShouldFileBeIgnored) {} + +// ItemFromDirWithErr returns error +func ItemFromDirWithErr(dir, file fs.Item) error { + return errors.New("Failed") +} + +// ItemFromDirWithSleep returns error +func ItemFromDirWithSleep(dir, file fs.Item) error { + time.Sleep(time.Millisecond * 600) + return remove.ItemFromDir(dir, file) +} + +// ItemFromDirWithSleepAndErr returns error +func ItemFromDirWithSleepAndErr(dir, file fs.Item) error { + time.Sleep(time.Millisecond * 600) + return errors.New("Failed") +} diff --git a/internal/testapp/app.go b/internal/testapp/app.go new file mode 100644 index 0000000..197acd1 --- /dev/null +++ b/internal/testapp/app.go @@ -0,0 +1,105 @@ +package testapp + +import ( + "errors" + "sync" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// CreateSimScreen returns tcell.SimulationScreen +func CreateSimScreen() tcell.SimulationScreen { + screen := tcell.NewSimulationScreen("UTF-8") + return screen +} + +// CreateTestAppWithSimScreen returns app with simulation screen for tests +func CreateTestAppWithSimScreen(width, height int) (app *tview.Application, screen tcell.SimulationScreen) { + app = tview.NewApplication() + screen = CreateSimScreen() + app.SetScreen(screen) + screen.SetSize(width, height) + return app, screen +} + +// MockedApp is tview.Application with mocked methods +type MockedApp struct { + mutex *sync.Mutex + updateDraws []func() + BeforeDraws []func(screen tcell.Screen) bool + FailRun bool +} + +// CreateMockedApp returns app with simulation screen for tests +func CreateMockedApp(failRun bool) common.TermApplication { + app := &MockedApp{ + FailRun: failRun, + updateDraws: make([]func(), 0, 1), + BeforeDraws: make([]func(screen tcell.Screen) bool, 0, 1), + mutex: &sync.Mutex{}, + } + return app +} + +// Run does nothing +func (app *MockedApp) Run() error { + if app.FailRun { + return errors.New("Fail") + } + + return nil +} + +// Stop does nothing +func (app *MockedApp) Stop() {} + +// Suspend runs given function +func (app *MockedApp) Suspend(f func()) bool { + f() + return true +} + +// SetRoot does nothing +func (app *MockedApp) SetRoot(root tview.Primitive, fullscreen bool) *tview.Application { + return nil +} + +// SetFocus does nothing +func (app *MockedApp) SetFocus(p tview.Primitive) *tview.Application { + return nil +} + +// SetInputCapture does nothing +func (app *MockedApp) SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) *tview.Application { + return nil +} + +// SetMouseCapture does nothing +func (app *MockedApp) SetMouseCapture( + capture func(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction), +) *tview.Application { + return nil +} + +// QueueUpdateDraw does nothing +func (app *MockedApp) QueueUpdateDraw(f func()) *tview.Application { + app.mutex.Lock() + app.updateDraws = append(app.updateDraws, f) + app.mutex.Unlock() + return nil +} + +// QueueUpdateDraw does nothing +func (app *MockedApp) GetUpdateDraws() []func() { + app.mutex.Lock() + defer app.mutex.Unlock() + return app.updateDraws +} + +// SetBeforeDrawFunc does nothing +func (app *MockedApp) SetBeforeDrawFunc(f func(screen tcell.Screen) bool) *tview.Application { + app.BeforeDraws = append(app.BeforeDraws, f) + return nil +} diff --git a/internal/testdata/test.json b/internal/testdata/test.json new file mode 100644 index 0000000..192e1d2 --- /dev/null +++ b/internal/testdata/test.json @@ -0,0 +1,7 @@ +[1,2,{"progname":"gdu","progver":"development","timestamp":1626807263}, +[{"name":"/home/gdu"}, +[{"name":"app"}, +{"name":"app.go","asize":4638,"dsize":8192}, +{"name":"app_linux_test.go","asize":1410,"dsize":4096}, +{"name":"app_test.go","asize":4974,"dsize":8192}], +{"name":"main.go","asize":3205,"dsize":4096}]] diff --git a/internal/testdata/wrong.json b/internal/testdata/wrong.json new file mode 100644 index 0000000..8adb9bb --- /dev/null +++ b/internal/testdata/wrong.json @@ -0,0 +1 @@ +[1,2,3,4] diff --git a/internal/testdev/dev.go b/internal/testdev/dev.go new file mode 100644 index 0000000..c0555de --- /dev/null +++ b/internal/testdev/dev.go @@ -0,0 +1,18 @@ +package testdev + +import "github.com/dundee/gdu/v5/pkg/device" + +// DevicesInfoGetterMock is mock of DevicesInfoGetter +type DevicesInfoGetterMock struct { + Devices device.Devices +} + +// GetDevicesInfo returns mocked devices +func (t DevicesInfoGetterMock) GetDevicesInfo() (devices device.Devices, err error) { + return t.Devices, nil +} + +// GetMounts returns all mounted filesystems from /proc/mounts +func (t DevicesInfoGetterMock) GetMounts() (devices device.Devices, err error) { + return t.Devices, nil +} diff --git a/internal/testdir/test_dir.go b/internal/testdir/test_dir.go new file mode 100644 index 0000000..14a5c8e --- /dev/null +++ b/internal/testdir/test_dir.go @@ -0,0 +1,30 @@ +package testdir + +import ( + "io/fs" + "os" +) + +// CreateTestDir creates test dir structure +func CreateTestDir() func() { + if err := os.MkdirAll("test_dir/nested/subnested", os.ModePerm); err != nil { + panic(err) + } + if err := os.WriteFile("test_dir/nested/subnested/file", []byte("hello"), 0o600); err != nil { + panic(err) + } + if err := os.WriteFile("test_dir/nested/file2", []byte("go"), 0o600); err != nil { + panic(err) + } + return func() { + err := os.RemoveAll("test_dir") + if err != nil { + panic(err) + } + } +} + +// MockedPathChecker is mocked os.Stat, returns (nil, nil) +func MockedPathChecker(path string) (info fs.FileInfo, err error) { + return nil, nil +} diff --git a/pkg/analyze/dir_linux-openbsd.go b/pkg/analyze/dir_linux-openbsd.go new file mode 100644 index 0000000..33564f2 --- /dev/null +++ b/pkg/analyze/dir_linux-openbsd.go @@ -0,0 +1,44 @@ +//go:build linux || openbsd + +package analyze + +import ( + "os" + "syscall" + "time" +) + +const devBSize = 512 + +func setPlatformSpecificAttrs(file *File, f os.FileInfo) { + if stat, ok := f.Sys().(*syscall.Stat_t); ok { + file.Usage = stat.Blocks * devBSize + file.Mtime = time.Unix(int64(stat.Mtim.Sec), int64(stat.Mtim.Nsec)) + + if stat.Nlink > 1 { + file.Mli = stat.Ino + } + } +} + +func setDirPlatformSpecificAttrs(dir *Dir, path string) { + var stat syscall.Stat_t + if err := syscall.Stat(path, &stat); err != nil { + return + } + + dir.Mtime = time.Unix(int64(stat.Mtim.Sec), int64(stat.Mtim.Nsec)) +} + +// getSyscallStats extracts usage and inode info from os.FileInfo using syscall +func getSyscallStats(info os.FileInfo) (usage int64, mli uint64) { + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + usage = stat.Blocks * 512 // 512-byte blocks + if stat.Nlink > 1 { + mli = stat.Ino + } + } else { + usage = info.Size() + } + return +} diff --git a/pkg/analyze/dir_linux_test.go b/pkg/analyze/dir_linux_test.go new file mode 100644 index 0000000..7942b6a --- /dev/null +++ b/pkg/analyze/dir_linux_test.go @@ -0,0 +1,64 @@ +//go:build linux + +package analyze + +import ( + "os" + "testing" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Chmod("test_dir/nested", 0) + assert.Nil(t, err) + defer func() { + err = os.Chmod("test_dir/nested", 0o755) + assert.Nil(t, err) + }() + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + assert.Equal(t, "test_dir", dir.GetName()) + assert.Equal(t, int64(2), dir.ItemCount) + assert.Equal(t, '.', dir.GetFlag()) + + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, '!', dir.Files[0].GetFlag()) +} + +func TestSeqErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Chmod("test_dir/nested", 0) + assert.Nil(t, err) + defer func() { + err = os.Chmod("test_dir/nested", 0o755) + assert.Nil(t, err) + }() + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + assert.Equal(t, "test_dir", dir.GetName()) + assert.Equal(t, int64(2), dir.ItemCount) + assert.Equal(t, '.', dir.GetFlag()) + + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, '!', dir.Files[0].GetFlag()) +} diff --git a/pkg/analyze/dir_other.go b/pkg/analyze/dir_other.go new file mode 100644 index 0000000..b2941da --- /dev/null +++ b/pkg/analyze/dir_other.go @@ -0,0 +1,29 @@ +//go:build windows || plan9 + +package analyze + +import ( + "os" + "syscall" + "time" +) + +func setPlatformSpecificAttrs(file *File, f os.FileInfo) { + stat := f.Sys().(*syscall.Win32FileAttributeData) + file.Mtime = time.Unix(0, stat.LastWriteTime.Nanoseconds()) + file.Usage = f.Size() // No block info on Windows, use apparent size +} + +func setDirPlatformSpecificAttrs(dir *Dir, path string) { + stat, err := os.Stat(path) + if err != nil { + return + } + dir.Mtime = stat.ModTime() +} + +// getSyscallStats extracts usage and inode info from os.FileInfo using syscall +func getSyscallStats(info os.FileInfo) (usage int64, mli uint64) { + usage = info.Size() + return +} diff --git a/pkg/analyze/dir_test.go b/pkg/analyze/dir_test.go new file mode 100644 index 0000000..e8924d1 --- /dev/null +++ b/pkg/analyze/dir_test.go @@ -0,0 +1,354 @@ +package analyze + +import ( + "os" + "sort" + "testing" + + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestAnalyzeDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + + progress := <-analyzer.GetProgressChan() + assert.GreaterOrEqual(t, progress.TotalSize, int64(0)) + + analyzer.GetDone().Wait() + analyzer.ResetProgress() + dir.UpdateStats(make(fs.HardLinkedItems)) + + // test dir info + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, int64(7+4096*3), dir.Size) + assert.Equal(t, int64(5), dir.ItemCount) + assert.True(t, dir.IsDir()) + + // test dir tree + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "subnested", dir.Files[0].(*Dir).Files[1].GetName()) + + // test file + assert.Equal(t, "file2", dir.Files[0].(*Dir).Files[0].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[0].GetSize()) + + assert.Equal( + t, "file", dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetName(), + ) + assert.Equal( + t, int64(5), dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetSize(), + ) + + // test parent link + assert.Equal( + t, + "test_dir", + dir.Files[0].(*Dir). + Files[1].(*Dir). + Files[0]. + GetParent(). + GetParent(). + GetParent(). + GetName(), + ) +} + +func TestIgnoreDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := CreateAnalyzer().AnalyzeDir( + "test_dir", func(_, _ string) bool { return true }, func(_ string) bool { return false }, + ).(*Dir) + + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, int64(1), dir.ItemCount) +} + +func TestFlags(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("test_dir/nested/file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(28+4096*4), dir.Size) + assert.Equal(t, int64(7), dir.ItemCount) + + // test file3 + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(21), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, '@', dir.Files[0].(*Dir).Files[1].GetFlag()) + + assert.Equal(t, 'e', dir.Files[1].GetFlag()) +} + +func TestHardlink(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Link("test_dir/nested/file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + assert.Equal(t, int64(7+4096*3), dir.Size) // file2 and file3 are counted just once for size + assert.Equal(t, int64(6), dir.ItemCount) // but twice for item count + + // test file3 + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, 'H', dir.Files[0].(*Dir).Files[1].GetFlag()) +} + +func TestFollowSymlink(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("./file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateAnalyzer() + analyzer.SetFollowSymlinks(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(9+4096*4), dir.Size) + assert.Equal(t, int64(7), dir.ItemCount) + + // test file3 + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, ' ', dir.Files[0].(*Dir).Files[1].GetFlag()) + + assert.Equal(t, 'e', dir.Files[1].GetFlag()) +} + +func TestGitAnnexSymlink(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink( + ".git/annex/objects/qx/qX/SHA256E-s967858083--"+ + "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4/SHA256E-s967858083--"+ + "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4", + "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateAnalyzer() + analyzer.SetFollowSymlinks(true) + analyzer.SetShowAnnexedSize(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(967858083+7+4096*4), dir.Size) + assert.Equal(t, int64(7), dir.ItemCount) + + // test file3 + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(967858083), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, '@', dir.Files[0].(*Dir).Files[1].GetFlag()) + + assert.Equal(t, 'e', dir.Files[1].GetFlag()) +} + +func TestBrokenSymlinkSkipped(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("xxx", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateAnalyzer() + analyzer.SetFollowSymlinks(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(7+4096*4), dir.Size) + assert.Equal(t, int64(6), dir.ItemCount) + + assert.Equal(t, '!', dir.Files[0].GetFlag()) +} + +func BenchmarkAnalyzeDir(b *testing.B) { + fin := testdir.CreateTestDir() + defer fin() + + b.ResetTimer() + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) +} + +func TestParallelStableOrderAnalyzerDeterminism(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + // Run parallel analyzer multiple times and verify results are identical + var results [][]string + for i := 0; i < 5; i++ { + analyzer := CreateStableOrderAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + names := getFileNames(dir) + results = append(results, names) + } + + // All runs should produce identical results + for i := 1; i < len(results); i++ { + assert.Equal(t, results[0], results[i], + "Parallel analyzer run %d produced different results than run 0", i) + } +} + +func TestParallelVsSequentialConsistency(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + // Run sequential analyzer + seqAnalyzer := CreateSeqAnalyzer() + seqDir := seqAnalyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ) + seqAnalyzer.GetDone().Wait() + seqDir.UpdateStats(make(fs.HardLinkedItems)) + seqNames := getFileNames(seqDir) + + // Run parallel analyzer + parAnalyzer := CreateStableOrderAnalyzer() + parDir := parAnalyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ) + parAnalyzer.GetDone().Wait() + parDir.UpdateStats(make(fs.HardLinkedItems)) + parNames := getFileNames(parDir) + + // Results should match + assert.Equal(t, seqNames, parNames, + "Parallel and sequential analyzers produced different results") +} + +func TestFileDirectoryInterleaving(t *testing.T) { + // Create test directory with interleaved files and directories + err := os.MkdirAll("test_interleave/aaa_dir", 0755) + assert.NoError(t, err) + err = os.WriteFile("test_interleave/bbb_file", []byte("content"), 0644) + assert.NoError(t, err) + err = os.MkdirAll("test_interleave/ccc_dir", 0755) + assert.NoError(t, err) + err = os.WriteFile("test_interleave/ddd_file", []byte("content"), 0644) + assert.NoError(t, err) + defer os.RemoveAll("test_interleave") + + // Run sequential analyzer + seqAnalyzer := CreateSeqAnalyzer() + seqDir := seqAnalyzer.AnalyzeDir( + "test_interleave", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + seqAnalyzer.GetDone().Wait() + + // Run parallel analyzer + parAnalyzer := CreateStableOrderAnalyzer() + parDir := parAnalyzer.AnalyzeDir( + "test_interleave", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + parAnalyzer.GetDone().Wait() + + // Extract file/dir names in order + seqOrder := make([]string, len(seqDir.Files)) + for i, item := range seqDir.Files { + seqOrder[i] = item.GetName() + } + + parOrder := make([]string, len(parDir.Files)) + for i, item := range parDir.Files { + parOrder[i] = item.GetName() + } + + // The order must be identical: [aaa_dir, bbb_file, ccc_dir, ddd_file] + assert.Equal(t, seqOrder, parOrder, + "Parallel analyzer did not preserve file/directory interleaving") + + // Verify the expected order (alphabetical from os.ReadDir) + assert.Equal(t, "aaa_dir", seqOrder[0]) + assert.Equal(t, "bbb_file", seqOrder[1]) + assert.Equal(t, "ccc_dir", seqOrder[2]) + assert.Equal(t, "ddd_file", seqOrder[3]) +} + +// getFileNames recursively collects file names from a directory tree +func getFileNames(item fs.Item) []string { + names := []string{item.GetName()} + if item.IsDir() { + for child := range item.GetFiles(fs.SortByName, fs.SortAsc) { + names = append(names, getFileNames(child)...) + } + } + return names +} diff --git a/pkg/analyze/dir_unix.go b/pkg/analyze/dir_unix.go new file mode 100644 index 0000000..512a466 --- /dev/null +++ b/pkg/analyze/dir_unix.go @@ -0,0 +1,44 @@ +//go:build darwin || netbsd || freebsd + +package analyze + +import ( + "os" + "syscall" + "time" +) + +const devBSize = 512 + +func setPlatformSpecificAttrs(file *File, f os.FileInfo) { + if stat, ok := f.Sys().(*syscall.Stat_t); ok { + file.Usage = stat.Blocks * devBSize + file.Mtime = time.Unix(int64(stat.Mtimespec.Sec), int64(stat.Mtimespec.Nsec)) + + if stat.Nlink > 1 { + file.Mli = stat.Ino + } + } +} + +func setDirPlatformSpecificAttrs(dir *Dir, path string) { + var stat syscall.Stat_t + if err := syscall.Stat(path, &stat); err != nil { + return + } + + dir.Mtime = time.Unix(int64(stat.Mtimespec.Sec), int64(stat.Mtimespec.Nsec)) +} + +// getSyscallStats extracts usage and inode info from os.FileInfo using syscall +func getSyscallStats(info os.FileInfo) (usage int64, mli uint64) { + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + usage = stat.Blocks * 512 // 512-byte blocks + if stat.Nlink > 1 { + mli = stat.Ino + } + } else { + usage = info.Size() + } + return +} diff --git a/pkg/analyze/encode.go b/pkg/analyze/encode.go new file mode 100644 index 0000000..3db9932 --- /dev/null +++ b/pkg/analyze/encode.go @@ -0,0 +1,101 @@ +package analyze + +import ( + "encoding/json" + "io" + "strconv" +) + +// EncodeJSON writes JSON representation of dir +func (f *Dir) EncodeJSON(writer io.Writer, topLevel bool) error { + buff := make([]byte, 0, 20) + + buff = append(buff, []byte(`[{"name":`)...) + + if topLevel { + if err := addString(&buff, f.GetPath()); err != nil { + return err + } + } else { + if err := addString(&buff, f.GetName()); err != nil { + return err + } + } + + if !f.GetMtime().IsZero() { + buff = append(buff, []byte(`,"mtime":`)...) + buff = append(buff, []byte(strconv.FormatInt(f.GetMtime().Unix(), 10))...) + } + + buff = append(buff, '}') + if f.Files.Len() > 0 { + buff = append(buff, ',') + } + buff = append(buff, '\n') + + if _, err := writer.Write(buff); err != nil { + return err + } + + for i, item := range f.Files { + if i > 0 { + if _, err := writer.Write([]byte(",\n")); err != nil { + return err + } + } + err := item.EncodeJSON(writer, false) + if err != nil { + return err + } + } + + if _, err := writer.Write([]byte("]")); err != nil { + return err + } + return nil +} + +// EncodeJSON writes JSON representation of file +func (f *File) EncodeJSON(writer io.Writer, topLevel bool) error { + buff := make([]byte, 0, 20) + + buff = append(buff, []byte(`{"name":`)...) + if err := addString(&buff, f.GetName()); err != nil { + return err + } + if f.GetSize() > 0 { + buff = append(buff, []byte(`,"asize":`)...) + buff = append(buff, []byte(strconv.FormatInt(f.GetSize(), 10))...) + } + if f.GetUsage() > 0 { + buff = append(buff, []byte(`,"dsize":`)...) + buff = append(buff, []byte(strconv.FormatInt(f.GetUsage(), 10))...) + } + if !f.GetMtime().IsZero() { + buff = append(buff, []byte(`,"mtime":`)...) + buff = append(buff, []byte(strconv.FormatInt(f.GetMtime().Unix(), 10))...) + } + + if f.Flag == '@' { + buff = append(buff, []byte(`,"notreg":true`)...) + } + if f.Flag == 'H' { + buff = append(buff, []byte(`,"ino":`+strconv.FormatUint(f.Mli, 10)+`,"hlnkc":true`)...) + } + + buff = append(buff, '}') + + if _, err := writer.Write(buff); err != nil { + return err + } + return nil +} + +func addString(buff *[]byte, val string) error { + b, err := json.Marshal(val) + if err != nil { + return err + } + *buff = append(*buff, b...) + return err +} diff --git a/pkg/analyze/encode_test.go b/pkg/analyze/encode_test.go new file mode 100644 index 0000000..819ccb4 --- /dev/null +++ b/pkg/analyze/encode_test.go @@ -0,0 +1,68 @@ +package analyze + +import ( + "bytes" + "testing" + "time" + + "github.com/dundee/gdu/v5/pkg/fs" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestEncode(t *testing.T) { + dir := &Dir{ + File: &File{ + Name: "test_dir", + Size: 10, + Usage: 18, + Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), + }, + ItemCount: 4, + BasePath: ".", + } + + subdir := &Dir{ + File: &File{ + Name: "nested", + Size: 9, + Usage: 14, + Parent: dir, + }, + ItemCount: 3, + } + file := &File{ + Name: "file2", + Size: 3, + Usage: 4, + Parent: subdir, + } + file2 := &File{ + Name: "file", + Size: 5, + Usage: 6, + Parent: subdir, + Flag: '@', + Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), + } + file3 := &File{ + Name: "file3", + Mli: 1234, + Flag: 'H', + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file, file2, file3} + + var buff bytes.Buffer + err := dir.EncodeJSON(&buff, true) + + assert.Nil(t, err) + assert.Contains(t, buff.String(), `"name":"nested"`) + assert.Contains(t, buff.String(), `"mtime":1629333600`) + assert.Contains(t, buff.String(), `"ino":1234`) + assert.Contains(t, buff.String(), `"hlnkc":true`) +} diff --git a/pkg/analyze/file.go b/pkg/analyze/file.go new file mode 100644 index 0000000..27c8286 --- /dev/null +++ b/pkg/analyze/file.go @@ -0,0 +1,324 @@ +package analyze + +import ( + "iter" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/dundee/gdu/v5/pkg/fs" +) + +// File struct +type File struct { + Mtime time.Time + Parent fs.Item + Name string + Size int64 + Usage int64 + Mli uint64 + Flag rune +} + +// GetName returns name of dir +func (f *File) GetName() string { + return f.Name +} + +// IsDir returns false for file +func (f *File) IsDir() bool { + return false +} + +// GetParent returns parent dir +func (f *File) GetParent() fs.Item { + return f.Parent +} + +// SetParent sets parent dir +func (f *File) SetParent(parent fs.Item) { + f.Parent = parent +} + +// GetPath returns absolute Get of the file +func (f *File) GetPath() string { + return filepath.Join(f.Parent.GetPath(), f.Name) +} + +// GetFlag returns flag of the file +func (f *File) GetFlag() rune { + return f.Flag +} + +// GetSize returns size of the file +func (f *File) GetSize() int64 { + return f.Size +} + +// GetUsage returns usage of the file +func (f *File) GetUsage() int64 { + return f.Usage +} + +// GetMtime returns mtime of the file +func (f *File) GetMtime() time.Time { + return f.Mtime +} + +// GetType returns name type of item +func (f *File) GetType() string { + if f.Flag == '@' { + return "Other" + } + return "File" +} + +// GetItemCount returns 1 for file +func (f *File) GetItemCount() int64 { + return 1 +} + +// GetMultiLinkedInode returns inode number of multilinked file +func (f *File) GetMultiLinkedInode() uint64 { + return f.Mli +} + +func (f *File) alreadyCounted(linkedItems fs.HardLinkedItems) bool { + mli := f.Mli + counted := false + if mli > 0 { + f.Flag = 'H' + if _, ok := linkedItems[mli]; ok { + counted = true + } + linkedItems[mli] = append(linkedItems[mli], f) + } + return counted +} + +// GetItemStats returns 1 as count of items, apparent usage and real usage of this file +func (f *File) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount, size, usage int64) { + if f.alreadyCounted(linkedItems) { + return 1, 0, 0 + } + return 1, f.GetSize(), f.GetUsage() +} + +// UpdateStats does nothing on file +func (f *File) UpdateStats(linkedItems fs.HardLinkedItems) {} + +// GetFiles returns all files in directory +func (f *File) GetFiles(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { + return func(yield func(fs.Item) bool) {} +} + +// GetFilesLocked returns all files in directory +func (f *File) GetFilesLocked(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { + return f.GetFiles(sortBy, order) +} + +// RLock panics on file +func (f *File) RLock() func() { + panic("RLock should not be called on file") +} + +// AddFile panics on file +func (f *File) AddFile(item fs.Item) { + panic("AddFile should not be called on file") +} + +// RemoveFile panics on file +func (f *File) RemoveFile(item fs.Item) { + panic("RemoveFile should not be called on file") +} + +// RemoveFileByName panics on file +func (f *File) RemoveFileByName(name string) { + panic("RemoveFileByName should not be called on file") +} + +// Dir struct +type Dir struct { + *File + BasePath string + Files fs.Files + ItemCount int64 + m sync.RWMutex +} + +// AddFile add item to files +func (f *Dir) AddFile(item fs.Item) { + f.Files = append(f.Files, item) +} + +// GetFiles returns all files in directory as a sorted iterator +func (f *Dir) GetFiles(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { + return func(yield func(fs.Item) bool) { + // Make a copy to avoid modifying the original slice + sorted := make(fs.Files, len(f.Files)) + copy(sorted, f.Files) + sortFiles(sorted, sortBy, order) + + for _, item := range sorted { + if !yield(item) { + return + } + } + } +} + +// GetFilesLocked returns all files in directory as a sorted iterator +// It is safe to call this function from multiple goroutines +func (f *Dir) GetFilesLocked(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { + return func(yield func(fs.Item) bool) { + f.m.RLock() + defer f.m.RUnlock() + + // Make a copy to avoid modifying the original slice + sorted := make(fs.Files, len(f.Files)) + copy(sorted, f.Files) + sortFiles(sorted, sortBy, order) + + for _, item := range sorted { + if !yield(item) { + return + } + } + } +} + +// GetType returns name type of item +func (f *Dir) GetType() string { + return "Directory" +} + +// GetItemCount returns number of files in dir +func (f *Dir) GetItemCount() int64 { + f.m.RLock() + defer f.m.RUnlock() + return f.ItemCount +} + +// IsDir returns true for dir +func (f *Dir) IsDir() bool { + return true +} + +// GetPath returns absolute path of the file +func (f *Dir) GetPath() string { + if f.BasePath != "" { + return filepath.Join(f.BasePath, f.Name) + } + if f.Parent != nil { + return filepath.Join(f.Parent.GetPath(), f.Name) + } + return f.Name +} + +// GetItemStats returns item count, apparent usage and real usage of this dir +func (f *Dir) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount, size, usage int64) { + f.UpdateStats(linkedItems) + return f.ItemCount, f.GetSize(), f.GetUsage() +} + +// UpdateStats recursively updates size and item count +func (f *Dir) UpdateStats(linkedItems fs.HardLinkedItems) { + totalSize := int64(4096) + totalUsage := int64(4096) + var itemCount int64 + for _, entry := range f.Files { + count, size, usage := entry.GetItemStats(linkedItems) + totalSize += size + totalUsage += usage + itemCount += count + + if entry.GetMtime().After(f.Mtime) { + f.Mtime = entry.GetMtime() + } + + switch entry.GetFlag() { + case '!', '.': + if f.Flag != '!' { + f.Flag = '.' + } + } + } + f.ItemCount = itemCount + 1 + f.Size = totalSize + f.Usage = totalUsage +} + +// RemoveFile removes item from dir, updates size and item count +func (f *Dir) RemoveFile(item fs.Item) { + f.m.Lock() + defer f.m.Unlock() + + f.Files = f.Files.Remove(item) + + cur := f + for { + cur.ItemCount -= item.GetItemCount() + cur.Size -= item.GetSize() + cur.Usage -= item.GetUsage() + + if cur.Parent == nil { + break + } + cur = cur.Parent.(*Dir) + } +} + +// sortFiles sorts files in place according to sortBy and order +func sortFiles(files fs.Files, sortBy fs.SortBy, order fs.SortOrder) { + var sorter sort.Interface + switch sortBy { + case fs.SortByName: + sorter = fs.ByName(files) + case fs.SortByItemCount: + sorter = fs.ByItemCount(files) + case fs.SortByMtime: + sorter = fs.ByMtime(files) + case fs.SortByApparentSize: + sorter = fs.ByApparentSize(files) + case fs.SortBySize: + sorter = files + } + + if order == fs.SortDesc { + sort.Sort(sort.Reverse(sorter)) + } else { + sort.Sort(sorter) + } +} + +// RLock read locks dir +func (f *Dir) RLock() func() { + f.m.RLock() + return f.m.RUnlock +} + +// RemoveFileByName removes item by name from dir +func (f *Dir) RemoveFileByName(name string) { + f.m.Lock() + defer f.m.Unlock() + + idx, ok := f.Files.FindByName(name) + if !ok { + return + } + item := f.Files[idx] + f.Files = append(f.Files[:idx], f.Files[idx+1:]...) + + cur := f + for { + cur.ItemCount -= item.GetItemCount() + cur.Size -= item.GetSize() + cur.Usage -= item.GetUsage() + + if cur.Parent == nil { + break + } + cur = cur.Parent.(*Dir) + } +} diff --git a/pkg/analyze/file_test.go b/pkg/analyze/file_test.go new file mode 100644 index 0000000..cda00d5 --- /dev/null +++ b/pkg/analyze/file_test.go @@ -0,0 +1,330 @@ +package analyze + +import ( + "slices" + "testing" + "time" + + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestIsDir(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + }, + ItemCount: 2, + } + file := &File{ + Name: "yyy", + Size: 2, + Parent: &dir, + } + dir.Files = fs.Files{file} + + assert.True(t, dir.IsDir()) + assert.False(t, file.IsDir()) +} + +func TestGetType(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + }, + ItemCount: 2, + } + file := &File{ + Name: "yyy", + Size: 2, + Parent: &dir, + Flag: ' ', + } + file2 := &File{ + Name: "yyy", + Size: 2, + Parent: &dir, + Flag: '@', + } + dir.Files = fs.Files{file, file2} + + assert.Equal(t, "Directory", dir.GetType()) + assert.Equal(t, "File", file.GetType()) + assert.Equal(t, "Other", file2.GetType()) +} + +func TestFind(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + }, + ItemCount: 2, + } + + file := &File{ + Name: "yyy", + Size: 2, + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Parent: &dir, + } + dir.Files = fs.Files{file, file2} + + i, _ := dir.Files.IndexOf(file) + assert.Equal(t, 0, i) + i, _ = dir.Files.IndexOf(file2) + assert.Equal(t, 1, i) +} + +func TestRemove(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + }, + ItemCount: 2, + } + + file := &File{ + Name: "yyy", + Size: 2, + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Parent: &dir, + } + dir.Files = fs.Files{file, file2} + + dir.Files = dir.Files.Remove(file) + + assert.Equal(t, 1, len(dir.Files)) + assert.Equal(t, file2, dir.Files[0]) +} + +func TestRemoveByName(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + Usage: 8, + }, + ItemCount: 2, + } + + file := &File{ + Name: "yyy", + Size: 2, + Usage: 4, + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Usage: 4, + Parent: &dir, + } + dir.Files = fs.Files{file, file2} + + dir.Files = dir.Files.RemoveByName("yyy") + + assert.Equal(t, 1, len(dir.Files)) + assert.Equal(t, file2, dir.Files[0]) +} + +func TestRemoveNotInDir(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + Usage: 8, + }, + ItemCount: 2, + } + + file := &File{ + Name: "yyy", + Size: 2, + Usage: 4, + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Usage: 4, + } + dir.Files = fs.Files{file} + + _, ok := dir.Files.IndexOf(file2) + assert.Equal(t, false, ok) + + dir.Files = dir.Files.Remove(file2) + + assert.Equal(t, 1, len(dir.Files)) +} + +func TestRemoveByNameNotInDir(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 5, + Usage: 8, + }, + ItemCount: 2, + } + + file := &File{ + Name: "yyy", + Size: 2, + Usage: 4, + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Usage: 4, + } + dir.Files = fs.Files{file} + + _, ok := dir.Files.IndexOf(file2) + assert.Equal(t, false, ok) + + dir.Files = dir.Files.RemoveByName("zzz") + + assert.Equal(t, 1, len(dir.Files)) +} + +func TestUpdateStats(t *testing.T) { + dir := Dir{ + File: &File{ + Name: "xxx", + Size: 1, + Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), + }, + ItemCount: 1, + } + + file := &File{ + Name: "yyy", + Size: 2, + Mtime: time.Date(2021, 8, 19, 0, 41, 0, 0, time.UTC), + Parent: &dir, + } + file2 := &File{ + Name: "zzz", + Size: 3, + Mtime: time.Date(2021, 8, 19, 0, 42, 0, 0, time.UTC), + Parent: &dir, + } + dir.Files = fs.Files{file, file2} + + dir.UpdateStats(nil) + + assert.Equal(t, int64(4096+5), dir.Size) + assert.Equal(t, 42, dir.GetMtime().Minute()) +} + +func TestGetMultiLinkedInode(t *testing.T) { + file := &File{ + Name: "xxx", + Mli: 5, + } + + assert.Equal(t, uint64(5), file.GetMultiLinkedInode()) +} + +func TestGetPathWithoutLeadingSlash(t *testing.T) { + dir := &Dir{ + File: &File{ + Name: "C:\\", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: "", + } + + assert.Equal(t, "C:\\", dir.GetPath()) +} + +func TestSetParent(t *testing.T) { + dir := &Dir{ + File: &File{ + Name: "root", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: "/", + } + file := &File{ + Name: "xxx", + Mli: 5, + } + file.SetParent(dir) + + assert.Equal(t, "root", file.GetParent().GetName()) +} + +func TestGetFiles(t *testing.T) { + file := &File{ + Name: "xxx", + Mli: 5, + } + dir := &Dir{ + File: &File{ + Name: "root", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: "/", + Files: fs.Files{file}, + } + + dirFiles := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Equal(t, file.Name, dirFiles[0].GetName()) + fileFiles := slices.Collect(file.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Equal(t, 0, len(fileFiles)) +} + +func TestGetFilesLocked(t *testing.T) { + file := &File{ + Name: "xxx", + Mli: 5, + } + dir := &Dir{ + File: &File{ + Name: "root", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: "/", + Files: fs.Files{file}, + } + + unlock := dir.RLock() + defer unlock() + files := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) + locked := slices.Collect(dir.GetFilesLocked(fs.SortByName, fs.SortAsc)) + assert.Equal(t, len(files), len(locked)) + assert.Equal(t, files[0].GetName(), locked[0].GetName()) +} + +func TestAddFilePanicsOnFile(t *testing.T) { + file := &File{ + Name: "xxx", + Mli: 5, + } + assert.Panics(t, func() { + file.AddFile(file) + }) +} diff --git a/pkg/analyze/parallel.go b/pkg/analyze/parallel.go new file mode 100644 index 0000000..7c84f3d --- /dev/null +++ b/pkg/analyze/parallel.go @@ -0,0 +1,278 @@ +package analyze + +import ( + "os" + "path/filepath" + "runtime" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/fs" + log "github.com/sirupsen/logrus" +) + +var concurrencyLimit = make(chan struct{}, 3*runtime.GOMAXPROCS(0)) + +// ParallelAnalyzer implements Analyzer +type ParallelAnalyzer struct { + progress *common.CurrentProgress + progressChan chan common.CurrentProgress + progressOutChan chan common.CurrentProgress + progressDoneChan chan struct{} + doneChan common.SignalGroup + wait *WaitGroup + ignoreDir common.ShouldDirBeIgnored + ignoreFileType common.ShouldFileBeIgnored + followSymlinks bool + gitAnnexedSize bool + matchesTimeFilterFn common.TimeFilter + archiveBrowsing bool +} + +// CreateAnalyzer returns Analyzer +func CreateAnalyzer() *ParallelAnalyzer { + return &ParallelAnalyzer{ + progress: &common.CurrentProgress{ + ItemCount: 0, + TotalSize: int64(0), + }, + progressChan: make(chan common.CurrentProgress, 1), + progressOutChan: make(chan common.CurrentProgress, 1), + progressDoneChan: make(chan struct{}), + doneChan: make(common.SignalGroup), + wait: (&WaitGroup{}).Init(), + } +} + +// SetFollowSymlinks sets whether symlink to files should be followed +func (a *ParallelAnalyzer) SetFollowSymlinks(v bool) { + a.followSymlinks = v +} + +// SetShowAnnexedSize sets whether to use annexed size of git-annex files +func (a *ParallelAnalyzer) SetShowAnnexedSize(v bool) { + a.gitAnnexedSize = v +} + +// SetTimeFilter sets the time filter function for file inclusion +func (a *ParallelAnalyzer) SetTimeFilter(matchesTimeFilterFn common.TimeFilter) { + a.matchesTimeFilterFn = matchesTimeFilterFn +} + +// SetArchiveBrowsing sets whether browsing of zip/jar archives is enabled +func (a *ParallelAnalyzer) SetArchiveBrowsing(v bool) { + a.archiveBrowsing = v +} + +// SetFileTypeFilter sets the file type filter function +func (a *ParallelAnalyzer) SetFileTypeFilter(filter common.ShouldFileBeIgnored) { + a.ignoreFileType = filter +} + +// GetProgressChan returns channel for getting progress +func (a *ParallelAnalyzer) GetProgressChan() chan common.CurrentProgress { + return a.progressOutChan +} + +// GetDone returns channel for checking when analysis is done +func (a *ParallelAnalyzer) GetDone() common.SignalGroup { + return a.doneChan +} + +// ResetProgress returns progress +func (a *ParallelAnalyzer) ResetProgress() { + a.progress = &common.CurrentProgress{} + a.progressChan = make(chan common.CurrentProgress, 1) + a.progressOutChan = make(chan common.CurrentProgress, 1) + a.progressDoneChan = make(chan struct{}) + a.doneChan = make(common.SignalGroup) + a.wait = (&WaitGroup{}).Init() +} + +// AnalyzeDir analyzes given path +func (a *ParallelAnalyzer) AnalyzeDir( + path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, +) fs.Item { + a.ignoreDir = ignore + a.ignoreFileType = fileTypeFilter + + go a.updateProgress() + dir := a.processDir(path) + + dir.BasePath = filepath.Dir(path) + a.wait.Wait() + + a.progressDoneChan <- struct{}{} + a.doneChan.Broadcast() + + return dir +} + +func (a *ParallelAnalyzer) processDir(path string) *Dir { + var ( + file fs.Item + err error + totalSize int64 + info os.FileInfo + subDirChan = make(chan *Dir) + dirCount int + ) + + a.wait.Add(1) + + files, err := os.ReadDir(path) + if err != nil { + log.Print(err.Error()) + } + + dir := &Dir{ + File: &File{ + Name: filepath.Base(path), + Flag: getDirFlag(err, len(files)), + }, + ItemCount: 1, + Files: make(fs.Files, 0, len(files)), + } + setDirPlatformSpecificAttrs(dir, path) + + for _, f := range files { + name := f.Name() + entryPath := filepath.Join(path, name) + if f.IsDir() { + if a.ignoreDir(name, entryPath) { + continue + } + dirCount++ + + go func(entryPath string) { + concurrencyLimit <- struct{}{} + subdir := a.processDir(entryPath) + subdir.Parent = dir + + subDirChan <- subdir + <-concurrencyLimit + }(entryPath) + } else { + info, err = f.Info() + if err != nil { + log.Print(err.Error()) + dir.Flag = '!' + continue + } + if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { + infoF, err := followSymlink(entryPath, a.gitAnnexedSize) + if err != nil { + log.Print(err.Error()) + dir.Flag = '!' + continue + } + if infoF != nil { + info = infoF + } + } + + // Check if it's a zip or jar file + if a.archiveBrowsing && isZipFile(name) { + zipDir, err := processZipFile(entryPath, info) + if err != nil { + // If unable to process zip file, treat as regular file + log.Printf("Failed to process zip file %s: %v", entryPath, err) + file = &File{ + Name: name, + Flag: getFlag(info), + Size: info.Size(), + Parent: dir, + } + } else { + // Successfully processed zip file, use zip content size + uncompressedSize, compressedSize, err := getZipFileSize(entryPath) + if err == nil { + zipDir.Size = uncompressedSize + zipDir.Usage = compressedSize + } + zipDir.Parent = dir + file = zipDir + } + } else { + file = &File{ + Name: name, + Flag: getFlag(info), + Size: info.Size(), + Parent: dir, + } + } + + // Apply time filter if set + if a.matchesTimeFilterFn != nil && !a.matchesTimeFilterFn(info.ModTime()) { + continue // Skip this file + } + + // Apply file type filter if set + if a.ignoreFileType != nil && a.ignoreFileType(name) { + continue // Skip this file + } + + if file != nil { + // Only set platform-specific attributes for regular files + if regularFile, ok := file.(*File); ok { + setPlatformSpecificAttrs(regularFile, info) + } + totalSize += file.GetUsage() + dir.AddFile(file) + } + } + } + + go func() { + var sub *Dir + + for i := 0; i < dirCount; i++ { + sub = <-subDirChan + dir.AddFile(sub) + } + + a.wait.Done() + }() + + a.progressChan <- common.CurrentProgress{ + CurrentItemName: path, + ItemCount: int64(len(files)), + TotalSize: totalSize, + } + return dir +} + +func (a *ParallelAnalyzer) updateProgress() { + for { + select { + case <-a.progressDoneChan: + return + case progress := <-a.progressChan: + a.progress.CurrentItemName = progress.CurrentItemName + a.progress.ItemCount += progress.ItemCount + a.progress.TotalSize += progress.TotalSize + } + + select { + case a.progressOutChan <- *a.progress: + default: + } + } +} + +func getDirFlag(err error, items int) rune { + switch { + case err != nil: + return '!' + case items == 0: + return 'e' + default: + return ' ' + } +} + +func getFlag(f os.FileInfo) rune { + if f.Mode()&os.ModeSymlink != 0 || f.Mode()&os.ModeSocket != 0 { + return '@' + } + return ' ' +} diff --git a/pkg/analyze/parallel_coverage_test.go b/pkg/analyze/parallel_coverage_test.go new file mode 100644 index 0000000..dd8b772 --- /dev/null +++ b/pkg/analyze/parallel_coverage_test.go @@ -0,0 +1,152 @@ +package analyze + +import ( + "os" + "testing" + "time" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/stretchr/testify/assert" +) + +func TestParallelAnalyzerSetFollowSymlinks(t *testing.T) { + analyzer := CreateAnalyzer() + analyzer.SetFollowSymlinks(true) + assert.True(t, analyzer.followSymlinks) + analyzer.SetFollowSymlinks(false) + assert.False(t, analyzer.followSymlinks) +} + +func TestParallelAnalyzerSetShowAnnexedSize(t *testing.T) { + analyzer := CreateAnalyzer() + analyzer.SetShowAnnexedSize(true) + assert.True(t, analyzer.gitAnnexedSize) + analyzer.SetShowAnnexedSize(false) + assert.False(t, analyzer.gitAnnexedSize) +} + +func TestGetDirFlagWithError(t *testing.T) { + flag := getDirFlag(os.ErrNotExist, 5) + assert.Equal(t, '!', flag) +} + +func TestGetDirFlagWithEmptyDir(t *testing.T) { + flag := getDirFlag(nil, 0) + assert.Equal(t, 'e', flag) +} + +func TestGetDirFlagWithNormalDir(t *testing.T) { + flag := getDirFlag(nil, 5) + assert.Equal(t, ' ', flag) +} + +func TestGetFlagWithSymlink(t *testing.T) { + // Create a temporary symlink + symlinkPath := "/tmp/test_symlink" + defer os.Remove(symlinkPath) + + err := os.Symlink("/tmp", symlinkPath) + assert.NoError(t, err) + + info, err := os.Lstat(symlinkPath) + assert.NoError(t, err) + + flag := getFlag(info) + assert.Equal(t, '@', flag) +} + +func TestGetFlagWithRegularFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + info, err := os.Stat("test_dir/nested/file2") + assert.NoError(t, err) + + flag := getFlag(info) + assert.Equal(t, ' ', flag) +} + +func TestParallelAnalyzerUpdateProgress(t *testing.T) { + analyzer := CreateAnalyzer() + + // Start the progress updater + go analyzer.updateProgress() + + // Send some progress updates + analyzer.progressChan <- struct { + CurrentItemName string + ItemCount int64 + TotalSize int64 + }{ + CurrentItemName: "test", + ItemCount: 5, + TotalSize: 100, + } + + // Wait a bit for the progress to be processed + time.Sleep(10 * time.Millisecond) + + // Send done signal + analyzer.progressDoneChan <- struct{}{} + + // Wait for the updater to finish + time.Sleep(10 * time.Millisecond) +} + +func TestParallelAnalyzerUpdateProgressWithDefaultCase(t *testing.T) { + analyzer := CreateAnalyzer() + + // Start the progress updater + go analyzer.updateProgress() + + // Send some progress updates + analyzer.progressChan <- struct { + CurrentItemName string + ItemCount int64 + TotalSize int64 + }{ + CurrentItemName: "test", + ItemCount: 5, + TotalSize: 100, + } + + // Wait a bit for the progress to be processed + time.Sleep(10 * time.Millisecond) + + // Send another progress update to trigger the default case + analyzer.progressChan <- struct { + CurrentItemName string + ItemCount int64 + TotalSize int64 + }{ + CurrentItemName: "test2", + ItemCount: 3, + TotalSize: 50, + } + + // Wait a bit for the progress to be processed + time.Sleep(10 * time.Millisecond) + + // Send done signal + analyzer.progressDoneChan <- struct{}{} + + // Wait for the updater to finish + time.Sleep(10 * time.Millisecond) +} + +func TestParallelAnalyzerAnalyzeDirWithIgnoreDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(name, _ string) bool { return name == "nested" }, func(_ string) bool { return false }, + ).(*Dir) + + analyzer.GetDone().Wait() + + assert.NotNil(t, dir) + assert.Equal(t, "test_dir", dir.Name) + // Should have fewer items since nested directory was ignored + assert.Less(t, dir.ItemCount, int64(5)) +} diff --git a/pkg/analyze/parallel_stable.go b/pkg/analyze/parallel_stable.go new file mode 100644 index 0000000..2c2c028 --- /dev/null +++ b/pkg/analyze/parallel_stable.go @@ -0,0 +1,230 @@ +package analyze + +import ( + "os" + "path/filepath" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/fs" + log "github.com/sirupsen/logrus" +) + +// ParallelStableOrderAnalyzer implements Analyzer +type ParallelStableOrderAnalyzer struct { + progress *common.CurrentProgress + progressChan chan common.CurrentProgress + progressOutChan chan common.CurrentProgress + progressDoneChan chan struct{} + doneChan common.SignalGroup + wait *WaitGroup + ignoreDir common.ShouldDirBeIgnored + ignoreFileType common.ShouldFileBeIgnored + followSymlinks bool + gitAnnexedSize bool +} + +// CreateStableOrderAnalyzer returns parallel Analyzer which keeps stable order of files +func CreateStableOrderAnalyzer() *ParallelStableOrderAnalyzer { + return &ParallelStableOrderAnalyzer{ + progress: &common.CurrentProgress{ + ItemCount: 0, + TotalSize: int64(0), + }, + progressChan: make(chan common.CurrentProgress, 1), + progressOutChan: make(chan common.CurrentProgress, 1), + progressDoneChan: make(chan struct{}), + doneChan: make(common.SignalGroup), + wait: (&WaitGroup{}).Init(), + } +} + +// SetFollowSymlinks sets whether symlink to files should be followed +func (a *ParallelStableOrderAnalyzer) SetFollowSymlinks(v bool) { + a.followSymlinks = v +} + +// SetShowAnnexedSize sets whether to use annexed size of git-annex files +func (a *ParallelStableOrderAnalyzer) SetShowAnnexedSize(v bool) { + a.gitAnnexedSize = v +} + +// SetFileTypeFilter sets the file type filter function +func (a *ParallelStableOrderAnalyzer) SetFileTypeFilter(filter common.ShouldFileBeIgnored) { + a.ignoreFileType = filter +} + +// GetProgressChan returns channel for getting progress +func (a *ParallelStableOrderAnalyzer) GetProgressChan() chan common.CurrentProgress { + return a.progressOutChan +} + +// GetDone returns channel for checking when analysis is done +func (a *ParallelStableOrderAnalyzer) GetDone() common.SignalGroup { + return a.doneChan +} + +// ResetProgress returns progress +func (a *ParallelStableOrderAnalyzer) ResetProgress() { + a.progress = &common.CurrentProgress{} + a.progressChan = make(chan common.CurrentProgress, 1) + a.progressOutChan = make(chan common.CurrentProgress, 1) + a.progressDoneChan = make(chan struct{}) + a.doneChan = make(common.SignalGroup) + a.wait = (&WaitGroup{}).Init() +} + +// AnalyzeDir analyzes given path +func (a *ParallelStableOrderAnalyzer) AnalyzeDir( + path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, +) fs.Item { + a.ignoreDir = ignore + a.ignoreFileType = fileTypeFilter + + go a.updateProgress() + dir := a.processDir(path) + + dir.BasePath = filepath.Dir(path) + a.wait.Wait() + + a.progressDoneChan <- struct{}{} + a.doneChan.Broadcast() + + return dir +} + +func (a *ParallelStableOrderAnalyzer) processDir(path string) *Dir { + type indexedItem struct { + index int + item fs.Item + } + + var ( + file *File + err error + totalSize int64 + info os.FileInfo + itemCount int + dirCount int + ) + + a.wait.Add(1) + + files, err := os.ReadDir(path) + if err != nil { + log.Print(err.Error()) + } + + dir := &Dir{ + File: &File{ + Name: filepath.Base(path), + Flag: getDirFlag(err, len(files)), + }, + ItemCount: 1, + Files: make(fs.Files, 0, len(files)), + } + setDirPlatformSpecificAttrs(dir, path) + + // Buffer channel to prevent deadlock when sending files synchronously + itemChan := make(chan indexedItem, len(files)) + + for _, f := range files { + name := f.Name() + entryPath := filepath.Join(path, name) + if f.IsDir() { + if a.ignoreDir(name, entryPath) { + continue + } + currentIndex := itemCount + itemCount++ + dirCount++ + + go func(entryPath string, idx int) { + concurrencyLimit <- struct{}{} + subdir := a.processDir(entryPath) + subdir.Parent = dir + + itemChan <- indexedItem{idx, subdir} + <-concurrencyLimit + }(entryPath, currentIndex) + } else { + info, err = f.Info() + if err != nil { + log.Print(err.Error()) + dir.Flag = '!' + continue + } + if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { + infoF, err := followSymlink(entryPath, a.gitAnnexedSize) + if err != nil { + log.Print(err.Error()) + dir.Flag = '!' + continue + } + if infoF != nil { + info = infoF + } + } + + // Apply file type filter if set + if a.ignoreFileType != nil && a.ignoreFileType(name) { + continue // Skip this file + } + + file = &File{ + Name: name, + Flag: getFlag(info), + Size: info.Size(), + Parent: dir, + } + setPlatformSpecificAttrs(file, info) + + totalSize += file.Usage + + // Send file to channel with its index + itemChan <- indexedItem{itemCount, file} + itemCount++ + } + } + + go func() { + items := make([]indexedItem, itemCount) + + // Collect all items (both files and subdirs) + for i := 0; i < itemCount; i++ { + indexed := <-itemChan + items[indexed.index] = indexed + } + + // Add all items in their original order + for i := 0; i < itemCount; i++ { + dir.AddFile(items[i].item) + } + + a.wait.Done() + }() + + a.progressChan <- common.CurrentProgress{ + CurrentItemName: path, + ItemCount: int64(len(files)), + TotalSize: totalSize, + } + return dir +} + +func (a *ParallelStableOrderAnalyzer) updateProgress() { + for { + select { + case <-a.progressDoneChan: + return + case progress := <-a.progressChan: + a.progress.CurrentItemName = progress.CurrentItemName + a.progress.ItemCount += progress.ItemCount + a.progress.TotalSize += progress.TotalSize + } + + select { + case a.progressOutChan <- *a.progress: + default: + } + } +} diff --git a/pkg/analyze/sequential.go b/pkg/analyze/sequential.go new file mode 100644 index 0000000..181ecca --- /dev/null +++ b/pkg/analyze/sequential.go @@ -0,0 +1,236 @@ +package analyze + +import ( + "os" + "path/filepath" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/fs" + log "github.com/sirupsen/logrus" +) + +// SequentialAnalyzer implements Analyzer +type SequentialAnalyzer struct { + progress *common.CurrentProgress + progressChan chan common.CurrentProgress + progressOutChan chan common.CurrentProgress + progressDoneChan chan struct{} + doneChan common.SignalGroup + wait *WaitGroup + ignoreDir common.ShouldDirBeIgnored + ignoreFileType common.ShouldFileBeIgnored + followSymlinks bool + gitAnnexedSize bool + matchesTimeFilterFn common.TimeFilter + archiveBrowsing bool +} + +// CreateSeqAnalyzer returns Analyzer +func CreateSeqAnalyzer() *SequentialAnalyzer { + return &SequentialAnalyzer{ + progress: &common.CurrentProgress{ + ItemCount: 0, + TotalSize: int64(0), + }, + progressChan: make(chan common.CurrentProgress, 1), + progressOutChan: make(chan common.CurrentProgress, 1), + progressDoneChan: make(chan struct{}), + doneChan: make(common.SignalGroup), + wait: (&WaitGroup{}).Init(), + } +} + +// SetFollowSymlinks sets whether symlink to files should be followed +func (a *SequentialAnalyzer) SetFollowSymlinks(v bool) { + a.followSymlinks = v +} + +// SetShowAnnexedSize sets whether to use annexed size of git-annex files +func (a *SequentialAnalyzer) SetShowAnnexedSize(v bool) { + a.gitAnnexedSize = v +} + +// SetTimeFilter sets the time filter function for file inclusion +func (a *SequentialAnalyzer) SetTimeFilter(matchesTimeFilterFn common.TimeFilter) { + a.matchesTimeFilterFn = matchesTimeFilterFn +} + +// SetArchiveBrowsing sets whether browsing of zip/jar archives is enabled +func (a *SequentialAnalyzer) SetArchiveBrowsing(v bool) { + a.archiveBrowsing = v +} + +// SetFileTypeFilter sets the file type filter function +func (a *SequentialAnalyzer) SetFileTypeFilter(filter common.ShouldFileBeIgnored) { + a.ignoreFileType = filter +} + +// GetProgressChan returns channel for getting progress +func (a *SequentialAnalyzer) GetProgressChan() chan common.CurrentProgress { + return a.progressOutChan +} + +// GetDone returns channel for checking when analysis is done +func (a *SequentialAnalyzer) GetDone() common.SignalGroup { + return a.doneChan +} + +// ResetProgress returns progress +func (a *SequentialAnalyzer) ResetProgress() { + a.progress = &common.CurrentProgress{} + a.progressChan = make(chan common.CurrentProgress, 1) + a.progressOutChan = make(chan common.CurrentProgress, 1) + a.progressDoneChan = make(chan struct{}) + a.doneChan = make(common.SignalGroup) +} + +// AnalyzeDir analyzes given path +func (a *SequentialAnalyzer) AnalyzeDir( + path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, +) fs.Item { + a.ignoreDir = ignore + a.ignoreFileType = fileTypeFilter + + go a.updateProgress() + dir := a.processDir(path) + + dir.BasePath = filepath.Dir(path) + + a.progressDoneChan <- struct{}{} + a.doneChan.Broadcast() + + return dir +} + +func (a *SequentialAnalyzer) processDir(path string) *Dir { + var ( + file fs.Item + err error + totalSize int64 + info os.FileInfo + dirCount int + ) + + files, err := os.ReadDir(path) + if err != nil { + log.Print(err.Error()) + } + + dir := &Dir{ + File: &File{ + Name: filepath.Base(path), + Flag: getDirFlag(err, len(files)), + }, + ItemCount: 1, + Files: make(fs.Files, 0, len(files)), + } + setDirPlatformSpecificAttrs(dir, path) + + for _, f := range files { + name := f.Name() + entryPath := filepath.Join(path, name) + if f.IsDir() { + if a.ignoreDir(name, entryPath) { + continue + } + dirCount++ + + subdir := a.processDir(entryPath) + subdir.Parent = dir + dir.AddFile(subdir) + } else { + info, err = f.Info() + if err != nil { + log.Print(err.Error()) + dir.Flag = '!' + continue + } + if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { + infoF, err := followSymlink(entryPath, a.gitAnnexedSize) + if err != nil { + log.Print(err.Error()) + dir.Flag = '!' + continue + } + if infoF != nil { + info = infoF + } + } + + // Check if it's a zip or jar file + if a.archiveBrowsing && isZipFile(name) { + zipDir, err := processZipFile(entryPath, info) + if err != nil { + // If unable to process zip file, treat as regular file + log.Printf("Failed to process zip file %s: %v", entryPath, err) + file = &File{ + Name: name, + Flag: getFlag(info), + Size: info.Size(), + Parent: dir, + } + } else { + // Successfully processed zip file, use zip content size + uncompressedSize, compressedSize, err := getZipFileSize(entryPath) + if err == nil { + zipDir.Size = uncompressedSize + zipDir.Usage = compressedSize + } + zipDir.Parent = dir + file = zipDir + } + } else { + file = &File{ + Name: name, + Flag: getFlag(info), + Size: info.Size(), + Parent: dir, + } + } + + // Apply time filter if set + if a.matchesTimeFilterFn != nil && !a.matchesTimeFilterFn(info.ModTime()) { + continue // Skip this file + } + + // Apply file type filter if set + if a.ignoreFileType != nil && a.ignoreFileType(name) { + continue // Skip this file + } + + if file != nil { + // Only set platform-specific attributes for regular files + if regularFile, ok := file.(*File); ok { + setPlatformSpecificAttrs(regularFile, info) + } + totalSize += file.GetUsage() + dir.AddFile(file) + } + } + } + + a.progressChan <- common.CurrentProgress{ + CurrentItemName: path, + ItemCount: int64(len(files)), + TotalSize: totalSize, + } + return dir +} + +func (a *SequentialAnalyzer) updateProgress() { + for { + select { + case <-a.progressDoneChan: + return + case progress := <-a.progressChan: + a.progress.CurrentItemName = progress.CurrentItemName + a.progress.ItemCount += progress.ItemCount + a.progress.TotalSize += progress.TotalSize + } + + select { + case a.progressOutChan <- *a.progress: + default: + } + } +} diff --git a/pkg/analyze/sequential_coverage_test.go b/pkg/analyze/sequential_coverage_test.go new file mode 100644 index 0000000..bf8dc02 --- /dev/null +++ b/pkg/analyze/sequential_coverage_test.go @@ -0,0 +1,110 @@ +package analyze + +import ( + "testing" + "time" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/stretchr/testify/assert" +) + +func TestSequentialAnalyzerSetFollowSymlinks(t *testing.T) { + analyzer := CreateSeqAnalyzer() + analyzer.SetFollowSymlinks(true) + assert.True(t, analyzer.followSymlinks) + analyzer.SetFollowSymlinks(false) + assert.False(t, analyzer.followSymlinks) +} + +func TestSequentialAnalyzerSetShowAnnexedSize(t *testing.T) { + analyzer := CreateSeqAnalyzer() + analyzer.SetShowAnnexedSize(true) + assert.True(t, analyzer.gitAnnexedSize) + analyzer.SetShowAnnexedSize(false) + assert.False(t, analyzer.gitAnnexedSize) +} + +func TestSequentialAnalyzerUpdateProgress(t *testing.T) { + analyzer := CreateSeqAnalyzer() + + // Start the progress updater + go analyzer.updateProgress() + + // Send some progress updates + analyzer.progressChan <- struct { + CurrentItemName string + ItemCount int64 + TotalSize int64 + }{ + CurrentItemName: "test", + ItemCount: 5, + TotalSize: 100, + } + + // Wait a bit for the progress to be processed + time.Sleep(10 * time.Millisecond) + + // Send done signal + analyzer.progressDoneChan <- struct{}{} + + // Wait for the updater to finish + time.Sleep(10 * time.Millisecond) +} + +func TestSequentialAnalyzerUpdateProgressWithDefaultCase(t *testing.T) { + analyzer := CreateSeqAnalyzer() + + // Start the progress updater + go analyzer.updateProgress() + + // Send some progress updates + analyzer.progressChan <- struct { + CurrentItemName string + ItemCount int64 + TotalSize int64 + }{ + CurrentItemName: "test", + ItemCount: 5, + TotalSize: 100, + } + + // Wait a bit for the progress to be processed + time.Sleep(10 * time.Millisecond) + + // Send another progress update to trigger the default case + analyzer.progressChan <- struct { + CurrentItemName string + ItemCount int64 + TotalSize int64 + }{ + CurrentItemName: "test2", + ItemCount: 3, + TotalSize: 50, + } + + // Wait a bit for the progress to be processed + time.Sleep(10 * time.Millisecond) + + // Send done signal + analyzer.progressDoneChan <- struct{}{} + + // Wait for the updater to finish + time.Sleep(10 * time.Millisecond) +} + +func TestSequentialAnalyzerAnalyzeDirWithIgnoreDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(name, _ string) bool { return name == "nested" }, func(_ string) bool { return false }, + ).(*Dir) + + analyzer.GetDone().Wait() + + assert.NotNil(t, dir) + assert.Equal(t, "test_dir", dir.Name) + // Should have fewer items since nested directory was ignored + assert.Less(t, dir.ItemCount, int64(5)) +} diff --git a/pkg/analyze/sequential_test.go b/pkg/analyze/sequential_test.go new file mode 100644 index 0000000..166f3e1 --- /dev/null +++ b/pkg/analyze/sequential_test.go @@ -0,0 +1,206 @@ +package analyze + +import ( + "os" + "sort" + "testing" + + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestAnalyzeDirSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + + progress := <-analyzer.GetProgressChan() + assert.GreaterOrEqual(t, progress.TotalSize, int64(0)) + + analyzer.GetDone().Wait() + analyzer.ResetProgress() + dir.UpdateStats(make(fs.HardLinkedItems)) + + // test dir info + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, int64(7+4096*3), dir.Size) + assert.Equal(t, int64(5), dir.ItemCount) + assert.True(t, dir.IsDir()) + + // test dir tree + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "subnested", dir.Files[0].(*Dir).Files[1].GetName()) + + // test file + assert.Equal(t, "file2", dir.Files[0].(*Dir).Files[0].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[0].GetSize()) + + assert.Equal( + t, "file", dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetName(), + ) + assert.Equal( + t, int64(5), dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetSize(), + ) + + // test parent link + assert.Equal( + t, + "test_dir", + dir.Files[0].(*Dir). + Files[1].(*Dir). + Files[0]. + GetParent(). + GetParent(). + GetParent(). + GetName(), + ) +} + +func TestIgnoreDirSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := CreateSeqAnalyzer().AnalyzeDir( + "test_dir", func(_, _ string) bool { return true }, func(_ string) bool { return false }, + ).(*Dir) + + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, int64(1), dir.ItemCount) +} + +func TestFlagsSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("test_dir/nested/file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(28+4096*4), dir.Size) + assert.Equal(t, int64(7), dir.ItemCount) + + // test file3 + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(21), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, '@', dir.Files[0].(*Dir).Files[1].GetFlag()) + + assert.Equal(t, 'e', dir.Files[1].GetFlag()) +} + +func TestHardlinkSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Link("test_dir/nested/file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + assert.Equal(t, int64(7+4096*3), dir.Size) // file2 and file3 are counted just once for size + assert.Equal(t, int64(6), dir.ItemCount) // but twice for item count + + // test file3 + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, 'H', dir.Files[0].(*Dir).Files[1].GetFlag()) +} + +func TestFollowSymlinkSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("./file2", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateSeqAnalyzer() + analyzer.SetFollowSymlinks(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(9+4096*4), dir.Size) + assert.Equal(t, int64(7), dir.ItemCount) + + // test file3 + assert.Equal(t, "nested", dir.Files[0].GetName()) + assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) + assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) + assert.Equal(t, ' ', dir.Files[0].(*Dir).Files[1].GetFlag()) + + assert.Equal(t, 'e', dir.Files[1].GetFlag()) +} + +func TestBrokenSymlinkSkippedSeq(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink("xxx", "test_dir/nested/file3") + assert.Nil(t, err) + + analyzer := CreateSeqAnalyzer() + analyzer.SetFollowSymlinks(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + sort.Sort(sort.Reverse(dir.Files)) + + assert.Equal(t, int64(7+4096*4), dir.Size) + assert.Equal(t, int64(6), dir.ItemCount) + + assert.Equal(t, '!', dir.Files[0].GetFlag()) +} + +func BenchmarkAnalyzeDirSeq(b *testing.B) { + fin := testdir.CreateTestDir() + defer fin() + + b.ResetTimer() + + analyzer := CreateSeqAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) +} diff --git a/pkg/analyze/sort_test.go b/pkg/analyze/sort_test.go new file mode 100644 index 0000000..9e3dc18 --- /dev/null +++ b/pkg/analyze/sort_test.go @@ -0,0 +1,193 @@ +package analyze + +import ( + "sort" + "testing" + "time" + + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestSortByUsage(t *testing.T) { + files := fs.Files{ + &File{ + Usage: 1, + }, + &File{ + Usage: 2, + }, + &File{ + Usage: 3, + }, + } + + sort.Sort(sort.Reverse(files)) + + assert.Equal(t, int64(3), files[0].GetUsage()) + assert.Equal(t, int64(2), files[1].GetUsage()) + assert.Equal(t, int64(1), files[2].GetUsage()) +} + +func TestStableSortByUsage(t *testing.T) { + files := fs.Files{ + &File{ + Name: "aaa", + Usage: 1, + }, + &File{ + Name: "bbb", + Usage: 1, + }, + &File{ + Name: "ccc", + Usage: 3, + }, + } + + sort.Sort(sort.Reverse(files)) + + assert.Equal(t, "ccc", files[0].GetName()) + assert.Equal(t, "bbb", files[1].GetName()) + assert.Equal(t, "aaa", files[2].GetName()) +} + +func TestSortByUsageAsc(t *testing.T) { + files := fs.Files{ + &File{ + Size: 1, + }, + &File{ + Size: 2, + }, + &File{ + Size: 3, + }, + } + + sort.Sort(files) + + assert.Equal(t, int64(1), files[0].GetSize()) + assert.Equal(t, int64(2), files[1].GetSize()) + assert.Equal(t, int64(3), files[2].GetSize()) +} + +func TestSortBySize(t *testing.T) { + files := fs.Files{ + &File{ + Size: 1, + }, + &File{ + Size: 2, + }, + &File{ + Size: 3, + }, + } + + sort.Sort(sort.Reverse(fs.ByApparentSize(files))) + + assert.Equal(t, int64(3), files[0].GetSize()) + assert.Equal(t, int64(2), files[1].GetSize()) + assert.Equal(t, int64(1), files[2].GetSize()) +} + +func TestSortBySizeAsc(t *testing.T) { + files := fs.Files{ + &File{ + Size: 1, + }, + &File{ + Size: 2, + }, + &File{ + Size: 3, + }, + } + + sort.Sort(fs.ByApparentSize(files)) + + assert.Equal(t, int64(1), files[0].GetSize()) + assert.Equal(t, int64(2), files[1].GetSize()) + assert.Equal(t, int64(3), files[2].GetSize()) +} + +func TestSortByItemCount(t *testing.T) { + files := fs.Files{ + &Dir{ + ItemCount: 1, + }, + &Dir{ + ItemCount: 2, + }, + &Dir{ + ItemCount: 3, + }, + } + + sort.Sort(sort.Reverse(fs.ByItemCount(files))) + + assert.Equal(t, int64(3), files[0].GetItemCount()) + assert.Equal(t, int64(2), files[1].GetItemCount()) + assert.Equal(t, int64(1), files[2].GetItemCount()) +} + +func TestSortByName(t *testing.T) { + files := fs.Files{ + &File{ + Name: "aa", + }, + &File{ + Name: "bb", + }, + &File{ + Name: "cc", + }, + } + + sort.Sort(sort.Reverse(fs.ByName(files))) + + assert.Equal(t, "cc", files[0].GetName()) + assert.Equal(t, "bb", files[1].GetName()) + assert.Equal(t, "aa", files[2].GetName()) +} + +func TestNaturalSortByNameAsc(t *testing.T) { + files := fs.Files{ + &File{ + Name: "aa3", + }, + &File{ + Name: "aa20", + }, + &File{ + Name: "aa100", + }, + } + + sort.Sort(fs.ByName(files)) + + assert.Equal(t, "aa3", files[0].GetName()) + assert.Equal(t, "aa20", files[1].GetName()) + assert.Equal(t, "aa100", files[2].GetName()) +} + +func TestSortByMtime(t *testing.T) { + files := fs.Files{ + &File{ + Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), + }, + &File{ + Mtime: time.Date(2021, 8, 19, 0, 41, 0, 0, time.UTC), + }, + &File{ + Mtime: time.Date(2021, 8, 19, 0, 42, 0, 0, time.UTC), + }, + } + + sort.Sort(sort.Reverse(fs.ByMtime(files))) + + assert.Equal(t, 42, files[0].GetMtime().Minute()) + assert.Equal(t, 41, files[1].GetMtime().Minute()) + assert.Equal(t, 40, files[2].GetMtime().Minute()) +} diff --git a/pkg/analyze/sqlite.go b/pkg/analyze/sqlite.go new file mode 100644 index 0000000..96dddb7 --- /dev/null +++ b/pkg/analyze/sqlite.go @@ -0,0 +1,883 @@ +package analyze + +import ( + "database/sql" + "io" + "iter" + "os" + "path/filepath" + "sync" + "time" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +// SqliteStorage represents SQLite database storage +type SqliteStorage struct { + db *sql.DB + dbPath string + m sync.RWMutex + tx *sql.Tx + insertStmt *sql.Stmt + updateStmt *sql.Stmt + hasInodeStmt *sql.Stmt +} + +// NewSqliteStorage creates a new SQLite storage and initializes the schema +func NewSqliteStorage(dbPath string) (*SqliteStorage, error) { + parentDir := filepath.Dir(dbPath) + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create parent directory for SQLite database") + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + + storage := &SqliteStorage{ + db: db, + dbPath: dbPath, + } + + if err := storage.createTables(); err != nil { + db.Close() + return nil, err + } + + return storage, nil +} + +// createTables creates the database schema if it doesn't exist +func (s *SqliteStorage) createTables() error { + // Optimize for insertion speed + pragmas := ` + PRAGMA synchronous = OFF; + PRAGMA journal_mode = MEMORY; + PRAGMA cache_size = -64000; + PRAGMA temp_store = MEMORY; + ` + if _, err := s.db.Exec(pragmas); err != nil { + return err + } + + schema := ` + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY, + parent_id INTEGER REFERENCES items(id), + name TEXT NOT NULL, + is_dir INTEGER NOT NULL, + size INTEGER NOT NULL, + usage INTEGER NOT NULL, + mtime INTEGER NOT NULL, + item_count INTEGER NOT NULL DEFAULT 1, + mli INTEGER NOT NULL DEFAULT 0, + flag TEXT NOT NULL DEFAULT ' ' + ); + + CREATE INDEX IF NOT EXISTS idx_items_parent_id ON items(parent_id); + CREATE INDEX IF NOT EXISTS idx_items_mli ON items(mli) WHERE mli != 0; + + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT + ); + ` + + _, err := s.db.Exec(schema) + return err +} + +// Close closes the database connection +func (s *SqliteStorage) Close() error { + s.m.Lock() + defer s.m.Unlock() + if s.db != nil { + return s.db.Close() + } + return nil +} + +// ClearItems removes all items from the database +func (s *SqliteStorage) ClearItems() error { + _, err := s.db.Exec("DELETE FROM items") + return err +} + +// BeginBulkInsert starts a transaction and prepares statements for bulk insertion +func (s *SqliteStorage) BeginBulkInsert() error { + s.m.Lock() + defer s.m.Unlock() + + tx, err := s.db.Begin() + if err != nil { + return err + } + s.tx = tx + + s.insertStmt, err = tx.Prepare( + `INSERT INTO items (parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + if err != nil { + rollbackErr := tx.Rollback() + if rollbackErr != nil { + log.Errorf("failed to rollback transaction: %v", rollbackErr) + } + return err + } + + s.updateStmt, err = tx.Prepare( + `UPDATE items SET size = ?, usage = ?, item_count = ? WHERE id = ?`, + ) + if err != nil { + s.insertStmt.Close() + rollbackErr := tx.Rollback() + if rollbackErr != nil { + log.Errorf("failed to rollback transaction: %v", rollbackErr) + } + return err + } + + s.hasInodeStmt, err = tx.Prepare( + `SELECT 1 FROM items WHERE mli = ? LIMIT 1`, + ) + if err != nil { + s.insertStmt.Close() + s.updateStmt.Close() + rollbackErr := tx.Rollback() + if rollbackErr != nil { + log.Errorf("failed to rollback transaction: %v", rollbackErr) + } + return err + } + + return nil +} + +// EndBulkInsert commits the transaction and closes prepared statements +func (s *SqliteStorage) EndBulkInsert() error { + s.m.Lock() + defer s.m.Unlock() + + if s.insertStmt != nil { + s.insertStmt.Close() + s.insertStmt = nil + } + if s.updateStmt != nil { + s.updateStmt.Close() + s.updateStmt = nil + } + if s.hasInodeStmt != nil { + s.hasInodeStmt.Close() + s.hasInodeStmt = nil + } + if s.tx != nil { + err := s.tx.Commit() + s.tx = nil + return err + } + return nil +} + +// HasData returns true if the database contains analysis data +func (s *SqliteStorage) HasData() bool { + s.m.RLock() + defer s.m.RUnlock() + + var rowid int + err := s.db.QueryRow("SELECT MAX(rowid) FROM items").Scan(&rowid) + if err != nil { + return false + } + return rowid > 0 +} + +// HasInode returns true if a file with the given inode already exists in the database +func (s *SqliteStorage) HasInode(mli uint64) bool { + var exists int + var err error + + if s.hasInodeStmt != nil { + err = s.hasInodeStmt.QueryRow(mli).Scan(&exists) + } else { + s.m.RLock() + err = s.db.QueryRow(`SELECT 1 FROM items WHERE mli = ? LIMIT 1`, mli).Scan(&exists) + s.m.RUnlock() + } + + return err == nil +} + +// GetRootItem returns the root item (item with no parent) +func (s *SqliteStorage) GetRootItem() (*SqliteItem, error) { + s.m.RLock() + defer s.m.RUnlock() + + item := &SqliteItem{storage: s} + var parentID sql.NullInt64 + var isDirInt int + var mtimeUnix int64 + var flag string + + err := s.db.QueryRow( + `SELECT id, parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag + FROM items WHERE parent_id IS NULL LIMIT 1`, + ).Scan( + &item.id, &parentID, &item.name, &isDirInt, + &item.size, &item.usage, &mtimeUnix, &item.itemCount, + &item.mli, &flag, + ) + if err != nil { + return nil, err + } + + item.isDir = isDirInt == 1 + item.mtime = time.Unix(mtimeUnix, 0) + if flag != "" { + item.flag = rune(flag[0]) + } else { + item.flag = ' ' + } + + return item, nil +} + +// InsertItem inserts a file/directory item into the database +func (s *SqliteStorage) InsertItem( + parentID *int64, name string, isDir bool, size, usage int64, mtime time.Time, itemCount int, mli uint64, flag rune, +) (int64, error) { + isDirInt := 0 + if isDir { + isDirInt = 1 + } + + var result sql.Result + var err error + + // Use prepared statement if in bulk mode, otherwise use direct exec + if s.insertStmt != nil { + result, err = s.insertStmt.Exec(parentID, name, isDirInt, size, usage, mtime.Unix(), itemCount, mli, string(flag)) + } else { + s.m.Lock() + result, err = s.db.Exec( + `INSERT INTO items (parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + parentID, name, isDirInt, size, usage, mtime.Unix(), itemCount, mli, string(flag), + ) + s.m.Unlock() + } + if err != nil { + return 0, err + } + + return result.LastInsertId() +} + +// UpdateItem updates an existing item's stats +func (s *SqliteStorage) UpdateItem(id, size, usage, itemCount int64) error { + var err error + + // Use prepared statement if in bulk mode, otherwise use direct exec + if s.updateStmt != nil { + _, err = s.updateStmt.Exec(size, usage, itemCount, id) + } else { + s.m.Lock() + _, err = s.db.Exec( + `UPDATE items SET size = ?, usage = ?, item_count = ? WHERE id = ?`, + size, usage, itemCount, id, + ) + s.m.Unlock() + } + return err +} + +// GetChildren returns all children of a given parent ID +func (s *SqliteStorage) GetChildren(parentID int64) ([]*SqliteItem, error) { + s.m.RLock() + defer s.m.RUnlock() + + rows, err := s.db.Query( + `SELECT id, parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag + FROM items WHERE parent_id = ?`, + parentID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []*SqliteItem + for rows.Next() { + item := &SqliteItem{storage: s} + var parentID sql.NullInt64 + var isDirInt int + var mtimeUnix int64 + var flag string + + err := rows.Scan( + &item.id, &parentID, &item.name, &isDirInt, + &item.size, &item.usage, &mtimeUnix, &item.itemCount, + &item.mli, &flag, + ) + if err != nil { + return nil, err + } + + if parentID.Valid { + item.parentID = &parentID.Int64 + } + item.isDir = isDirInt == 1 + item.mtime = time.Unix(mtimeUnix, 0) + if flag != "" { + item.flag = rune(flag[0]) + } else { + item.flag = ' ' + } + items = append(items, item) + } + + return items, rows.Err() +} + +// GetItemByID returns an item by its ID +func (s *SqliteStorage) GetItemByID(id int64) (*SqliteItem, error) { + s.m.RLock() + defer s.m.RUnlock() + + item := &SqliteItem{storage: s} + var parentID sql.NullInt64 + var isDirInt int + var mtimeUnix int64 + var flag string + + err := s.db.QueryRow( + `SELECT id, parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag + FROM items WHERE id = ?`, + id, + ).Scan( + &item.id, &parentID, &item.name, &isDirInt, + &item.size, &item.usage, &mtimeUnix, &item.itemCount, + &item.mli, &flag, + ) + if err != nil { + return nil, err + } + + if parentID.Valid { + item.parentID = &parentID.Int64 + } + item.isDir = isDirInt == 1 + item.mtime = time.Unix(mtimeUnix, 0) + if flag != "" { + item.flag = rune(flag[0]) + } else { + item.flag = ' ' + } + + return item, nil +} + +// SetMetadata stores a metadata key-value pair +func (s *SqliteStorage) SetMetadata(key, value string) error { + s.m.Lock() + defer s.m.Unlock() + + _, err := s.db.Exec( + `INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)`, + key, value, + ) + return err +} + +// GetMetadata retrieves a metadata value by key +func (s *SqliteStorage) GetMetadata(key string) (string, error) { + s.m.RLock() + defer s.m.RUnlock() + + var value string + err := s.db.QueryRow(`SELECT value FROM metadata WHERE key = ?`, key).Scan(&value) + return value, err +} + +// SqliteItem represents a file or directory stored in SQLite +type SqliteItem struct { + storage *SqliteStorage + id int64 + parentID *int64 + name string + isDir bool + size int64 + usage int64 + mtime time.Time + itemCount int64 + mli uint64 + flag rune + parent fs.Item + m sync.RWMutex +} + +// GetPath returns the full path of the item +func (i *SqliteItem) GetPath() string { + if i.parent != nil { + return filepath.Join(i.parent.GetPath(), i.name) + } + // For root item, get basePath from metadata + basePath, err := i.storage.GetMetadata("top_dir_path") + if err != nil { + return i.name + } + return filepath.Join(filepath.Dir(basePath), i.name) +} + +// GetName returns the name of the item +func (i *SqliteItem) GetName() string { + return i.name +} + +// GetFlag returns the flag of the item +func (i *SqliteItem) GetFlag() rune { + return i.flag +} + +// IsDir returns true if the item is a directory +func (i *SqliteItem) IsDir() bool { + return i.isDir +} + +// GetSize returns the apparent size +func (i *SqliteItem) GetSize() int64 { + return i.size +} + +// GetType returns the type of the item +func (i *SqliteItem) GetType() string { + if i.isDir { + return "Directory" + } + if i.flag == '@' { + return "Other" + } + return "File" +} + +// GetUsage returns the disk usage +func (i *SqliteItem) GetUsage() int64 { + return i.usage +} + +// GetMtime returns the modification time +func (i *SqliteItem) GetMtime() time.Time { + return i.mtime +} + +// GetItemCount returns the item count +func (i *SqliteItem) GetItemCount() int64 { + return i.itemCount +} + +// GetParent returns the parent item +func (i *SqliteItem) GetParent() fs.Item { + if i.parent != nil { + return i.parent + } + if i.parentID == nil { + return nil + } + + parent, err := i.storage.GetItemByID(*i.parentID) + if err != nil { + log.Print(err.Error()) + return nil + } + i.parent = parent + return parent +} + +// SetParent sets the parent item +func (i *SqliteItem) SetParent(parent fs.Item) { + i.parent = parent +} + +// GetMultiLinkedInode returns the multi-linked inode number +func (i *SqliteItem) GetMultiLinkedInode() uint64 { + return i.mli +} + +// EncodeJSON encodes the item to JSON +func (i *SqliteItem) EncodeJSON(writer io.Writer, topLevel bool) error { + // Delegate to standard encoding logic + // This is a simplified version - full implementation would mirror Dir.EncodeJSON + return nil +} + +// GetItemStats returns item statistics - hard links already handled during scan +func (i *SqliteItem) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount, size, usage int64) { + return i.itemCount, i.size, i.usage +} + +// UpdateStats is a no-op for SqliteItem - hard links are handled during scan +func (i *SqliteItem) UpdateStats(linkedItems fs.HardLinkedItems) { +} + +// AddFile adds a child file (no-op for SQLite items - children are in DB) +func (i *SqliteItem) AddFile(item fs.Item) { + // Children are stored in database via parent_id relationship +} + +// GetFiles returns children as a sorted iterator +func (i *SqliteItem) GetFiles(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { + return func(yield func(fs.Item) bool) { + children, err := i.storage.GetChildren(i.id) + if err != nil { + log.Print(err.Error()) + return + } + + // Convert to fs.Files for sorting + files := make(fs.Files, len(children)) + for idx, child := range children { + child.parent = i + files[idx] = child + } + + sortFiles(files, sortBy, order) + + for _, item := range files { + if !yield(item) { + return + } + } + } +} + +// GetFilesLocked returns children with locking +func (i *SqliteItem) GetFilesLocked(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { + return i.GetFiles(sortBy, order) +} + +// RemoveFile removes a child file +func (i *SqliteItem) RemoveFile(item fs.Item) { + // TODO: implement deletion from database +} + +// RemoveFileByName removes a child by name +func (i *SqliteItem) RemoveFileByName(name string) { + // TODO: implement deletion from database +} + +// RLock returns a no-op unlock function +func (i *SqliteItem) RLock() func() { + i.m.RLock() + return i.m.RUnlock +} + +// SqliteAnalyzer implements Analyzer using SQLite storage +type SqliteAnalyzer struct { + storage *SqliteStorage + progress *common.CurrentProgress + progressChan chan common.CurrentProgress + progressOutChan chan common.CurrentProgress + progressDoneChan chan struct{} + doneChan common.SignalGroup + wait *WaitGroup + ignoreDir common.ShouldDirBeIgnored + ignoreFileType common.ShouldFileBeIgnored + followSymlinks bool + gitAnnexedSize bool + matchesTimeFilterFn common.TimeFilter + archiveBrowsing bool +} + +// CreateSqliteAnalyzer creates a new SQLite analyzer +func CreateSqliteAnalyzer(dbPath string) (*SqliteAnalyzer, error) { + if err := checkAvailable(); err != nil { + return nil, err + } + + storage, err := NewSqliteStorage(dbPath) + if err != nil { + return nil, err + } + + return &SqliteAnalyzer{ + storage: storage, + progress: &common.CurrentProgress{ + ItemCount: 0, + TotalSize: int64(0), + }, + progressChan: make(chan common.CurrentProgress, 1), + progressOutChan: make(chan common.CurrentProgress, 1), + progressDoneChan: make(chan struct{}), + doneChan: make(common.SignalGroup), + wait: (&WaitGroup{}).Init(), + }, nil +} + +// SetFollowSymlinks sets whether symlinks should be followed +func (a *SqliteAnalyzer) SetFollowSymlinks(v bool) { + a.followSymlinks = v +} + +// SetShowAnnexedSize sets whether to use annexed size +func (a *SqliteAnalyzer) SetShowAnnexedSize(v bool) { + a.gitAnnexedSize = v +} + +// SetTimeFilter sets the time filter function +func (a *SqliteAnalyzer) SetTimeFilter(matchesTimeFilterFn common.TimeFilter) { + a.matchesTimeFilterFn = matchesTimeFilterFn +} + +// SetArchiveBrowsing sets whether archive browsing is enabled +func (a *SqliteAnalyzer) SetArchiveBrowsing(v bool) { + a.archiveBrowsing = v +} + +// SetFileTypeFilter sets the file type filter +func (a *SqliteAnalyzer) SetFileTypeFilter(filter common.ShouldFileBeIgnored) { + a.ignoreFileType = filter +} + +// GetProgressChan returns the progress channel +func (a *SqliteAnalyzer) GetProgressChan() chan common.CurrentProgress { + return a.progressOutChan +} + +// GetDone returns the done signal group +func (a *SqliteAnalyzer) GetDone() common.SignalGroup { + return a.doneChan +} + +// ResetProgress resets the progress state +func (a *SqliteAnalyzer) ResetProgress() { + a.progress = &common.CurrentProgress{} + a.progressChan = make(chan common.CurrentProgress, 1) + a.progressOutChan = make(chan common.CurrentProgress, 1) + a.progressDoneChan = make(chan struct{}) + a.doneChan = make(common.SignalGroup) + a.wait = (&WaitGroup{}).Init() +} + +// AnalyzeDir analyzes the given path and stores results in SQLite. +// If the database already contains data, it loads from the database instead of re-scanning. +func (a *SqliteAnalyzer) AnalyzeDir( + path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, +) fs.Item { + // Check if database already has data + if a.storage.HasData() { + log.Printf("Loading analysis from existing SQLite database") + rootItem, err := a.storage.GetRootItem() + if err != nil { + log.Printf("Error loading from database, will re-scan: %v", err) + } else { + // Signal that we're done immediately + a.doneChan.Broadcast() + return rootItem + } + } + + a.ignoreDir = ignore + a.ignoreFileType = fileTypeFilter + + // Clear existing data and store metadata + err := a.storage.ClearItems() + if err != nil { + log.Printf("Error clearing items: %v", err) + } + err = a.storage.SetMetadata("top_dir_path", path) + if err != nil { + log.Printf("Error setting metadata: %v", err) + } + + // Start bulk insert transaction + if err := a.storage.BeginBulkInsert(); err != nil { + log.Printf("Error starting bulk insert: %v", err) + } + + go a.updateProgress() + + // Process directory and get the root item + rootItem := a.processDir(path, nil) + + a.wait.Wait() + + // Commit bulk insert transaction + if err := a.storage.EndBulkInsert(); err != nil { + log.Printf("Error committing bulk insert: %v", err) + } + + a.progressDoneChan <- struct{}{} + a.doneChan.Broadcast() + + return rootItem +} + +func (a *SqliteAnalyzer) processDir(path string, parentID *int64) *SqliteItem { + // Start with 4096 for directory's own size/usage, matching Dir.UpdateStats behavior + var ( + totalSize int64 = 4096 + totalUsage int64 = 4096 + filesSize int64 // only files in this directory, for progress reporting + itemCount int64 = 1 + ) + + a.wait.Add(1) + defer a.wait.Done() + + files, err := os.ReadDir(path) + if err != nil { + log.Print(err.Error()) + } + + // Get directory info for mtime + dirInfo, err := os.Stat(path) + var dirMtime time.Time + if err == nil { + dirMtime = dirInfo.ModTime() + } + + // Insert directory into database (size/usage will be updated later) + dirID, err := a.storage.InsertItem( + parentID, + filepath.Base(path), + true, + 0, // size will be updated later + 0, // usage will be updated later + dirMtime, + 1, // item_count will be updated later + 0, + getDirFlag(err, len(files)), + ) + if err != nil { + log.Print(err.Error()) + return nil + } + + // Process children + for _, f := range files { + name := f.Name() + entryPath := filepath.Join(path, name) + + if f.IsDir() { + if a.ignoreDir(name, entryPath) { + continue + } + + // Process subdirectory recursively + subItem := a.processDir(entryPath, &dirID) + if subItem != nil { + totalSize += subItem.size + totalUsage += subItem.usage + itemCount += subItem.itemCount + } + } else { + info, err := f.Info() + if err != nil { + log.Print(err.Error()) + continue + } + + if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { + infoF, err := followSymlink(entryPath, a.gitAnnexedSize) + if err != nil { + log.Print(err.Error()) + continue + } + if infoF != nil { + info = infoF + } + } + + // Apply time filter + if a.matchesTimeFilterFn != nil && !a.matchesTimeFilterFn(info.ModTime()) { + continue + } + + // Apply file type filter + if a.ignoreFileType != nil && a.ignoreFileType(name) { + continue + } + + fileSize := info.Size() + fileUsage, fileMli := getSyscallStats(info) + fileFlag := getFlag(info) + + // Handle hard links: if inode already seen, don't count size + if fileMli != 0 && a.storage.HasInode(fileMli) { + fileSize = 0 + fileUsage = 0 + fileFlag = 'H' + } + + _, err = a.storage.InsertItem( + &dirID, + name, + false, + fileSize, + fileUsage, + info.ModTime(), + 1, + fileMli, + fileFlag, + ) + if err != nil { + log.Print(err.Error()) + continue + } + + totalSize += fileSize + totalUsage += fileUsage + filesSize += fileUsage + itemCount++ + } + } + + // Update directory with computed stats + err = a.storage.UpdateItem(dirID, totalSize, totalUsage, itemCount) + if err != nil { + log.Printf("Error updating item: %v", err) + } + + // Report progress (only files in this dir, subdirs already reported themselves) + a.progressChan <- common.CurrentProgress{ + CurrentItemName: path, + ItemCount: int64(len(files)), + TotalSize: filesSize, + } + + // Return SqliteItem for the directory + return &SqliteItem{ + storage: a.storage, + id: dirID, + parentID: parentID, + name: filepath.Base(path), + isDir: true, + size: totalSize, + usage: totalUsage, + mtime: dirMtime, + itemCount: itemCount, + flag: getDirFlag(err, len(files)), + } +} + +func (a *SqliteAnalyzer) updateProgress() { + for { + select { + case <-a.progressDoneChan: + return + case progress := <-a.progressChan: + a.progress.CurrentItemName = progress.CurrentItemName + a.progress.ItemCount += progress.ItemCount + a.progress.TotalSize += progress.TotalSize + } + + select { + case a.progressOutChan <- *a.progress: + default: + } + } +} diff --git a/pkg/analyze/sqlite_modernc.go b/pkg/analyze/sqlite_modernc.go new file mode 100644 index 0000000..20d2a4b --- /dev/null +++ b/pkg/analyze/sqlite_modernc.go @@ -0,0 +1,13 @@ +//go:build (linux && !mips64 && !mipsle && !mips && !mips64le && !ppc64) || darwin || windows || (freebsd && !arm && !386) || (openbsd && !386) || (netbsd && !arm && !386 && !amd64) + +package analyze + +import ( + // nolint:revive // Why: importing SQLite driver for side effects + _ "modernc.org/sqlite" +) + +// checkAvailable checks if the modernc SQLite driver is available +func checkAvailable() error { + return nil +} diff --git a/pkg/analyze/sqlite_other.go b/pkg/analyze/sqlite_other.go new file mode 100644 index 0000000..23022cd --- /dev/null +++ b/pkg/analyze/sqlite_other.go @@ -0,0 +1,10 @@ +//go:build (linux && (mips64 || mipsle || mips || mips64le || ppc64)) || (freebsd && (arm || 386)) || (openbsd && 386) || (netbsd && (arm || 386 || amd64)) + +package analyze + +import "errors" + +// checkAvailable reports that the modernc SQLite driver is not available on this platform +func checkAvailable() error { + return errors.New("modernc SQLite driver is not available on this platform") +} diff --git a/pkg/analyze/sqlite_test.go b/pkg/analyze/sqlite_test.go new file mode 100644 index 0000000..255948c --- /dev/null +++ b/pkg/analyze/sqlite_test.go @@ -0,0 +1,932 @@ +//go:build (linux && !mips64 && !mipsle && !mips && !mips64le && !ppc64) || darwin || windows || (freebsd && !arm && !386) || (openbsd && !386) || (netbsd && !arm && !386 && !amd64) + +package analyze + +import ( + "os" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestNewSqliteStorage(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + assert.NotNil(t, storage) + defer storage.Close() + + // Test that the database is created + _, err = os.Stat(dbPath) + assert.NoError(t, err) +} + +func TestNewSqliteStorageNestedDir(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "nested", "dir", "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + assert.NotNil(t, storage) + defer storage.Close() + + // Test that the database is created + _, err = os.Stat(dbPath) + assert.NoError(t, err) +} + +func TestSqliteStorageClose(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + + err = storage.Close() + assert.NoError(t, err) + + // Closing again should not error + err = storage.Close() + assert.NoError(t, err) +} + +func TestSqliteStorageHasData(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // Initially no data + assert.False(t, storage.HasData()) + + // Insert an item + _, err = storage.InsertItem(nil, "root", true, 100, 100, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + // Now has data + assert.True(t, storage.HasData()) +} + +func TestSqliteStorageClearItems(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // Insert an item + _, err = storage.InsertItem(nil, "root", true, 100, 100, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + assert.True(t, storage.HasData()) + + // Clear items + err = storage.ClearItems() + assert.NoError(t, err) + assert.False(t, storage.HasData()) +} + +func TestSqliteStorageMetadata(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // Set metadata + err = storage.SetMetadata("key1", "value1") + assert.NoError(t, err) + + // Get metadata + value, err := storage.GetMetadata("key1") + assert.NoError(t, err) + assert.Equal(t, "value1", value) + + // Update metadata + err = storage.SetMetadata("key1", "value2") + assert.NoError(t, err) + + value, err = storage.GetMetadata("key1") + assert.NoError(t, err) + assert.Equal(t, "value2", value) + + // Get non-existent metadata + _, err = storage.GetMetadata("nonexistent") + assert.Error(t, err) +} + +func TestSqliteStorageInsertAndGetItem(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + mtime := time.Now().Truncate(time.Second) + + // Insert root directory + rootID, err := storage.InsertItem(nil, "root", true, 1000, 2000, mtime, 5, 0, ' ') + assert.NoError(t, err) + assert.Greater(t, rootID, int64(0)) + + // Get root item + root, err := storage.GetRootItem() + assert.NoError(t, err) + assert.Equal(t, "root", root.GetName()) + assert.True(t, root.IsDir()) + assert.Equal(t, int64(1000), root.GetSize()) + assert.Equal(t, int64(2000), root.GetUsage()) + assert.Equal(t, int64(5), root.GetItemCount()) + assert.Equal(t, ' ', root.GetFlag()) + assert.Equal(t, mtime, root.GetMtime()) +} + +func TestSqliteStorageInsertAndGetChildren(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + mtime := time.Now().Truncate(time.Second) + + // Insert root + rootID, err := storage.InsertItem(nil, "root", true, 0, 0, mtime, 1, 0, ' ') + assert.NoError(t, err) + + // Insert children + _, err = storage.InsertItem(&rootID, "file1.txt", false, 100, 4096, mtime, 1, 0, ' ') + assert.NoError(t, err) + _, err = storage.InsertItem(&rootID, "file2.txt", false, 200, 4096, mtime, 1, 12345, 'H') + assert.NoError(t, err) + _, err = storage.InsertItem(&rootID, "subdir", true, 500, 8192, mtime, 3, 0, ' ') + assert.NoError(t, err) + + // Get children + children, err := storage.GetChildren(rootID) + assert.NoError(t, err) + assert.Len(t, children, 3) + + // Verify children names + names := make([]string, len(children)) + for i, child := range children { + names[i] = child.GetName() + } + assert.Contains(t, names, "file1.txt") + assert.Contains(t, names, "file2.txt") + assert.Contains(t, names, "subdir") +} + +func TestSqliteStorageUpdateItem(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // Insert item + id, err := storage.InsertItem(nil, "dir", true, 100, 200, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + // Update item + err = storage.UpdateItem(id, 500, 1000, 10) + assert.NoError(t, err) + + // Verify update + item, err := storage.GetItemByID(id) + assert.NoError(t, err) + assert.Equal(t, int64(500), item.GetSize()) + assert.Equal(t, int64(1000), item.GetUsage()) + assert.Equal(t, int64(10), item.GetItemCount()) +} + +func TestSqliteStorageBulkInsert(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // Begin bulk insert + err = storage.BeginBulkInsert() + assert.NoError(t, err) + + // Insert many items + rootID, err := storage.InsertItem(nil, "root", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + for i := 0; i < 100; i++ { + _, err = storage.InsertItem(&rootID, "file", false, 100, 4096, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + } + + // Update during bulk mode + err = storage.UpdateItem(rootID, 10000, 20000, 101) + assert.NoError(t, err) + + // End bulk insert + err = storage.EndBulkInsert() + assert.NoError(t, err) + + // Verify + children, err := storage.GetChildren(rootID) + assert.NoError(t, err) + assert.Len(t, children, 100) +} + +func TestSqliteStorageHasInode(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // No inode initially + assert.False(t, storage.HasInode(12345)) + + // Insert item with inode + _, err = storage.InsertItem(nil, "file", false, 100, 4096, time.Now(), 1, 12345, 'H') + assert.NoError(t, err) + + // Now inode exists + assert.True(t, storage.HasInode(12345)) + assert.False(t, storage.HasInode(99999)) +} + +func TestSqliteStorageHasInodeBulkMode(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + err = storage.BeginBulkInsert() + assert.NoError(t, err) + + // Insert item with inode in bulk mode + _, err = storage.InsertItem(nil, "file", false, 100, 4096, time.Now(), 1, 12345, 'H') + assert.NoError(t, err) + + // Check inode during bulk mode (uses prepared statement) + assert.True(t, storage.HasInode(12345)) + assert.False(t, storage.HasInode(99999)) + + err = storage.EndBulkInsert() + assert.NoError(t, err) +} + +func TestSqliteItemGetPath(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // Set up metadata for path resolution + err = storage.SetMetadata("top_dir_path", "/home/user/testdir") + assert.NoError(t, err) + + // Insert root + rootID, err := storage.InsertItem(nil, "testdir", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + // Insert child + childID, err := storage.InsertItem(&rootID, "file.txt", false, 100, 4096, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + // Get root item + root, err := storage.GetItemByID(rootID) + assert.NoError(t, err) + assert.Equal(t, "/home/user/testdir", root.GetPath()) + + // Get child and set parent + child, err := storage.GetItemByID(childID) + assert.NoError(t, err) + child.SetParent(root) + assert.Equal(t, "/home/user/testdir/file.txt", child.GetPath()) +} + +func TestSqliteItemGetType(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // Directory + dirID, err := storage.InsertItem(nil, "dir", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + dir, _ := storage.GetItemByID(dirID) + assert.Equal(t, "Directory", dir.GetType()) + + // File + fileID, err := storage.InsertItem(nil, "file", false, 100, 4096, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + file, _ := storage.GetItemByID(fileID) + assert.Equal(t, "File", file.GetType()) + + // Other (symlink flag) + otherID, err := storage.InsertItem(nil, "symlink", false, 100, 4096, time.Now(), 1, 0, '@') + assert.NoError(t, err) + other, _ := storage.GetItemByID(otherID) + assert.Equal(t, "Other", other.GetType()) +} + +func TestSqliteItemGetParent(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // Insert root and child + rootID, err := storage.InsertItem(nil, "root", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + childID, err := storage.InsertItem(&rootID, "child", false, 100, 4096, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + // Get child + child, err := storage.GetItemByID(childID) + assert.NoError(t, err) + + // Get parent (lazy loaded) + parent := child.GetParent() + assert.NotNil(t, parent) + assert.Equal(t, "root", parent.GetName()) + + // Second call should use cached parent + parent2 := child.GetParent() + assert.Equal(t, parent, parent2) + + // Root item has no parent + root, err := storage.GetItemByID(rootID) + assert.NoError(t, err) + assert.Nil(t, root.GetParent()) +} + +func TestSqliteItemGetMultiLinkedInode(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // Insert item with inode + id, err := storage.InsertItem(nil, "file", false, 100, 4096, time.Now(), 1, 12345, 'H') + assert.NoError(t, err) + + item, err := storage.GetItemByID(id) + assert.NoError(t, err) + assert.Equal(t, uint64(12345), item.GetMultiLinkedInode()) +} + +func TestSqliteItemGetFiles(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + // Insert root and children with different usages (SortBySize sorts by usage) + rootID, err := storage.InsertItem(nil, "root", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + _, err = storage.InsertItem(&rootID, "small.txt", false, 100, 1000, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + _, err = storage.InsertItem(&rootID, "large.txt", false, 1000, 9000, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + _, err = storage.InsertItem(&rootID, "medium.txt", false, 500, 5000, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + root, err := storage.GetItemByID(rootID) + assert.NoError(t, err) + + // Sort by name ascending (alphabetical) + files := slices.Collect(root.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Len(t, files, 3) + assert.Equal(t, "large.txt", files[0].GetName()) + assert.Equal(t, "medium.txt", files[1].GetName()) + assert.Equal(t, "small.txt", files[2].GetName()) + + // Sort by size descending (largest usage first) + files = slices.Collect(root.GetFiles(fs.SortBySize, fs.SortDesc)) + assert.Len(t, files, 3) + assert.Equal(t, "large.txt", files[0].GetName()) + assert.Equal(t, "medium.txt", files[1].GetName()) + assert.Equal(t, "small.txt", files[2].GetName()) +} + +func TestSqliteItemGetFilesLocked(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + rootID, err := storage.InsertItem(nil, "root", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + _, err = storage.InsertItem(&rootID, "file.txt", false, 100, 4096, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + root, err := storage.GetItemByID(rootID) + assert.NoError(t, err) + + // GetFilesLocked should work same as GetFiles + files := slices.Collect(root.GetFilesLocked(fs.SortByName, fs.SortAsc)) + assert.Len(t, files, 1) + assert.Equal(t, "file.txt", files[0].GetName()) +} + +func TestSqliteItemRLock(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + id, err := storage.InsertItem(nil, "root", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + item, err := storage.GetItemByID(id) + assert.NoError(t, err) + + // RLock should return unlock function + unlock := item.RLock() + assert.NotNil(t, unlock) + unlock() +} + +func TestSqliteItemGetItemStats(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + id, err := storage.InsertItem(nil, "dir", true, 1000, 2000, time.Now(), 5, 0, ' ') + assert.NoError(t, err) + + item, err := storage.GetItemByID(id) + assert.NoError(t, err) + + count, size, usage := item.GetItemStats(make(fs.HardLinkedItems)) + assert.Equal(t, int64(5), count) + assert.Equal(t, int64(1000), size) + assert.Equal(t, int64(2000), usage) +} + +func TestSqliteItemUpdateStats(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + id, err := storage.InsertItem(nil, "dir", true, 1000, 2000, time.Now(), 5, 0, ' ') + assert.NoError(t, err) + + item, err := storage.GetItemByID(id) + assert.NoError(t, err) + + // UpdateStats is a no-op for SqliteItem + item.UpdateStats(make(fs.HardLinkedItems)) + // Just verify it doesn't panic +} + +func TestSqliteItemAddFile(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + id, err := storage.InsertItem(nil, "dir", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + item, err := storage.GetItemByID(id) + assert.NoError(t, err) + + // AddFile is a no-op for SqliteItem + item.AddFile(nil) + // Just verify it doesn't panic +} + +func TestSqliteItemRemoveFile(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + id, err := storage.InsertItem(nil, "dir", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + item, err := storage.GetItemByID(id) + assert.NoError(t, err) + + // RemoveFile is a no-op for SqliteItem + item.RemoveFile(nil) + // Just verify it doesn't panic +} + +func TestSqliteItemRemoveFileByName(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + id, err := storage.InsertItem(nil, "dir", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + item, err := storage.GetItemByID(id) + assert.NoError(t, err) + + // RemoveFileByName is a no-op for SqliteItem + item.RemoveFileByName("test") + // Just verify it doesn't panic +} + +func TestSqliteItemEncodeJSON(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + storage, err := NewSqliteStorage(dbPath) + assert.NoError(t, err) + defer storage.Close() + + id, err := storage.InsertItem(nil, "dir", true, 0, 0, time.Now(), 1, 0, ' ') + assert.NoError(t, err) + + item, err := storage.GetItemByID(id) + assert.NoError(t, err) + + // EncodeJSON returns nil (simplified implementation) + err = item.EncodeJSON(nil, false) + assert.NoError(t, err) +} + +func TestCreateSqliteAnalyzer(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + assert.NotNil(t, analyzer) + defer analyzer.storage.Close() +} + +func TestSqliteAnalyzerSetFollowSymlinks(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + analyzer.SetFollowSymlinks(true) + assert.True(t, analyzer.followSymlinks) + analyzer.SetFollowSymlinks(false) + assert.False(t, analyzer.followSymlinks) +} + +func TestSqliteAnalyzerSetShowAnnexedSize(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + analyzer.SetShowAnnexedSize(true) + assert.True(t, analyzer.gitAnnexedSize) + analyzer.SetShowAnnexedSize(false) + assert.False(t, analyzer.gitAnnexedSize) +} + +func TestSqliteAnalyzerSetArchiveBrowsing(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + analyzer.SetArchiveBrowsing(true) + assert.True(t, analyzer.archiveBrowsing) + analyzer.SetArchiveBrowsing(false) + assert.False(t, analyzer.archiveBrowsing) +} + +func TestSqliteAnalyzerSetTimeFilter(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + filter := func(mtime time.Time) bool { return true } + analyzer.SetTimeFilter(filter) + assert.NotNil(t, analyzer.matchesTimeFilterFn) +} + +func TestSqliteAnalyzerSetFileTypeFilter(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + filter := func(name string) bool { return false } + analyzer.SetFileTypeFilter(filter) + assert.NotNil(t, analyzer.ignoreFileType) +} + +func TestSqliteAnalyzerGetProgressChan(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + progressChan := analyzer.GetProgressChan() + assert.NotNil(t, progressChan) +} + +func TestSqliteAnalyzerGetDone(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + doneChan := analyzer.GetDone() + assert.NotNil(t, doneChan) +} + +func TestSqliteAnalyzerResetProgress(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + analyzer.ResetProgress() + assert.NotNil(t, analyzer.progress) + assert.NotNil(t, analyzer.progressChan) + assert.NotNil(t, analyzer.progressOutChan) + assert.NotNil(t, analyzer.doneChan) +} + +func TestSqliteAnalyzerAnalyzeDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*SqliteItem) + + analyzer.GetDone().Wait() + + // Test dir info + assert.Equal(t, "test_dir", dir.GetName()) + assert.True(t, dir.IsDir()) + assert.Equal(t, int64(5), dir.GetItemCount()) + // Size should include directory overhead + file sizes: 4096*3 + 7 bytes + assert.Equal(t, int64(7+4096*3), dir.GetSize()) + + // Test dir tree + files := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Equal(t, 1, len(files)) + assert.Equal(t, "nested", files[0].GetName()) + + nested := files[0].(*SqliteItem) + nestedFiles := slices.Collect(nested.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Equal(t, 2, len(nestedFiles)) + assert.Equal(t, "file2", nestedFiles[0].GetName()) + assert.Equal(t, "subnested", nestedFiles[1].GetName()) + + // Test file + assert.Equal(t, int64(2), nestedFiles[0].GetSize()) + + subnested := nestedFiles[1].(*SqliteItem) + subnestedFiles := slices.Collect(subnested.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Equal(t, "file", subnestedFiles[0].GetName()) + assert.Equal(t, int64(5), subnestedFiles[0].GetSize()) +} + +func TestSqliteAnalyzerIgnoreDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return true }, func(_ string) bool { return false }, + ).(*SqliteItem) + + analyzer.GetDone().Wait() + + assert.Equal(t, "test_dir", dir.GetName()) + assert.Equal(t, int64(1), dir.GetItemCount()) +} + +func TestSqliteAnalyzerIgnoreFileType(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + // Ignore all files + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return true }, + ).(*SqliteItem) + + analyzer.GetDone().Wait() + + // Only directories should remain + assert.Equal(t, "test_dir", dir.GetName()) + assert.Equal(t, int64(3), dir.GetItemCount()) // test_dir, nested, subnested +} + +func TestSqliteAnalyzerHardlinks(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + // Create hard link + err := os.Link("test_dir/nested/file2", "test_dir/nested/file3") + assert.NoError(t, err) + + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*SqliteItem) + + analyzer.GetDone().Wait() + + // file2 and file3 are counted just once for size but twice for item count + assert.Equal(t, int64(7+4096*3), dir.GetSize()) + assert.Equal(t, int64(6), dir.GetItemCount()) + + // Check hard link flag + nested := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc))[0].(*SqliteItem) + nestedFiles := slices.Collect(nested.GetFiles(fs.SortByName, fs.SortAsc)) + + var file3 *SqliteItem + for _, f := range nestedFiles { + if f.GetName() == "file3" { + file3 = f.(*SqliteItem) + break + } + } + assert.NotNil(t, file3) + assert.Equal(t, 'H', file3.GetFlag()) +} + +func TestSqliteAnalyzerSymlink(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + // Create symlink + err := os.Symlink("test_dir/nested/file2", "test_dir/nested/file3") + assert.NoError(t, err) + + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*SqliteItem) + + analyzer.GetDone().Wait() + + // Check symlink flag + nested := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc))[0].(*SqliteItem) + nestedFiles := slices.Collect(nested.GetFiles(fs.SortByName, fs.SortAsc)) + + var file3 *SqliteItem + for _, f := range nestedFiles { + if f.GetName() == "file3" { + file3 = f.(*SqliteItem) + break + } + } + assert.NotNil(t, file3) + assert.Equal(t, '@', file3.GetFlag()) +} + +func TestSqliteAnalyzerFollowSymlink(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + // Create symlink to file2 + err := os.Symlink("./file2", "test_dir/nested/file3") + assert.NoError(t, err) + + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + analyzer.SetFollowSymlinks(true) + + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*SqliteItem) + + analyzer.GetDone().Wait() + + // With followSymlinks, file3 should have same size as file2 + nested := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc))[0].(*SqliteItem) + nestedFiles := slices.Collect(nested.GetFiles(fs.SortByName, fs.SortAsc)) + + var file3 *SqliteItem + for _, f := range nestedFiles { + if f.GetName() == "file3" { + file3 = f.(*SqliteItem) + break + } + } + assert.NotNil(t, file3) + assert.Equal(t, int64(2), file3.GetSize()) + assert.Equal(t, ' ', file3.GetFlag()) // Not a symlink flag when followed +} + +func TestSqliteAnalyzerTimeFilter(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + // Filter out all files (mtime filter that always returns false) + analyzer.SetTimeFilter(func(mtime time.Time) bool { return false }) + + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*SqliteItem) + + analyzer.GetDone().Wait() + + // Only directories should remain + assert.Equal(t, int64(3), dir.GetItemCount()) // test_dir, nested, subnested +} + +func TestSqliteAnalyzerLoadFromExisting(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dbPath := filepath.Join(t.TempDir(), "test.db") + + // First analysis + analyzer1, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + + dir1 := analyzer1.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*SqliteItem) + analyzer1.GetDone().Wait() + + assert.Equal(t, "test_dir", dir1.GetName()) + assert.Equal(t, int64(5), dir1.GetItemCount()) + + analyzer1.storage.Close() + + // Second analysis should load from existing data + analyzer2, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer2.storage.Close() + + dir2 := analyzer2.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*SqliteItem) + analyzer2.GetDone().Wait() + + assert.Equal(t, "test_dir", dir2.GetName()) + assert.Equal(t, int64(5), dir2.GetItemCount()) +} + +func TestSqliteAnalyzerProgress(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dbPath := filepath.Join(t.TempDir(), "test.db") + analyzer, err := CreateSqliteAnalyzer(dbPath) + assert.NoError(t, err) + defer analyzer.storage.Close() + + // Start analysis in goroutine + go func() { + analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ) + }() + + // Receive at least one progress update + select { + case progress := <-analyzer.GetProgressChan(): + assert.GreaterOrEqual(t, progress.TotalSize, int64(0)) + case <-time.After(5 * time.Second): + t.Fatal("Timeout waiting for progress") + } + + analyzer.GetDone().Wait() +} + +func BenchmarkSqliteAnalyzeDir(b *testing.B) { + fin := testdir.CreateTestDir() + defer fin() + + for i := 0; i < b.N; i++ { + dbPath := filepath.Join(b.TempDir(), "test.db") + analyzer, _ := CreateSqliteAnalyzer(dbPath) + analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ) + analyzer.GetDone().Wait() + analyzer.storage.Close() + } +} diff --git a/pkg/analyze/storage.go b/pkg/analyze/storage.go new file mode 100644 index 0000000..9f2d04e --- /dev/null +++ b/pkg/analyze/storage.go @@ -0,0 +1,150 @@ +package analyze + +import ( + "bytes" + "encoding/gob" + "path/filepath" + "sync" + + "github.com/dgraph-io/badger/v4" + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/pkg/errors" +) + +func init() { + gob.RegisterName("analyze.StoredDir", &StoredDir{}) + gob.RegisterName("analyze.Dir", &Dir{}) + gob.RegisterName("analyze.File", &File{}) + gob.RegisterName("analyze.ParentDir", &ParentDir{}) +} + +// DefaultStorage is a default instance of badger storage +var DefaultStorage *Storage + +// Storage represents a badger storage +type Storage struct { + db *badger.DB + storagePath string + topDir string + m sync.RWMutex + counter int + counterM sync.Mutex +} + +// NewStorage returns new instance of badger storage +func NewStorage(storagePath, topDir string) *Storage { + st := &Storage{ + storagePath: storagePath, + topDir: topDir, + } + DefaultStorage = st + return st +} + +// GetTopDir returns top directory +func (s *Storage) GetTopDir() string { + return s.topDir +} + +// IsOpen returns true if badger DB is open +func (s *Storage) IsOpen() bool { + s.m.RLock() + defer s.m.RUnlock() + return s.db != nil +} + +// Open opens badger DB +func (s *Storage) Open() func() { + options := badger.DefaultOptions(s.storagePath) + options.Logger = nil + + if !common.Is64Bit { + // For 32-bit systems, we need to set ValueLogFileSize to a smaller value to + // avoid "cannot allocate memory while mmapping" error + options.ValueLogFileSize = (1<<30 - 1) / 2 + } + + db, err := badger.Open(options) + if err != nil { + panic(err) + } + s.db = db + + return func() { + s.db.Close() + s.db = nil + } +} + +// StoreDir saves item info into badger DB +func (s *Storage) StoreDir(dir fs.Item) error { + s.checkCount() + s.m.RLock() + defer s.m.RUnlock() + + return s.db.Update(func(txn *badger.Txn) error { + b := &bytes.Buffer{} + enc := gob.NewEncoder(b) + err := enc.Encode(dir) + if err != nil { + return errors.Wrap(err, "encoding dir value") + } + + return txn.Set([]byte(dir.GetPath()), b.Bytes()) + }) +} + +// LoadDir saves item info into badger DB +func (s *Storage) LoadDir(dir fs.Item) error { + s.checkCount() + s.m.RLock() + defer s.m.RUnlock() + + return s.db.View(func(txn *badger.Txn) error { + path := dir.GetPath() + item, err := txn.Get([]byte(path)) + if err != nil { + return errors.Wrap(err, "reading stored value for path: "+path) + } + return item.Value(func(val []byte) error { + b := bytes.NewBuffer(val) + dec := gob.NewDecoder(b) + return dec.Decode(dir) + }) + }) +} + +// GetDirForPath returns Dir for given path +func (s *Storage) GetDirForPath(path string) (item fs.Item, err error) { + dirPath := filepath.Dir(path) + name := filepath.Base(path) + dir := &StoredDir{ + &Dir{ + File: &File{ + Name: name, + }, + BasePath: dirPath, + }, + nil, + sync.Mutex{}, + } + err = s.LoadDir(dir) + if err != nil { + return nil, err + } + return dir, nil +} + +func (s *Storage) checkCount() { + s.counterM.Lock() + defer s.counterM.Unlock() + s.counter++ + if s.counter >= 10000 { + s.m.Lock() + defer s.m.Unlock() + s.counter = 0 + s.db.Close() + s.Open() + } +} diff --git a/pkg/analyze/stored.go b/pkg/analyze/stored.go new file mode 100644 index 0000000..4e0010e --- /dev/null +++ b/pkg/analyze/stored.go @@ -0,0 +1,507 @@ +package analyze + +import ( + "io" + "iter" + "os" + "path/filepath" + "sync" + "time" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/fs" + log "github.com/sirupsen/logrus" +) + +// StoredAnalyzer implements Analyzer +type StoredAnalyzer struct { + storage *Storage + progress *common.CurrentProgress + progressChan chan common.CurrentProgress + progressOutChan chan common.CurrentProgress + progressDoneChan chan struct{} + doneChan common.SignalGroup + wait *WaitGroup + ignoreDir common.ShouldDirBeIgnored + ignoreFileType common.ShouldFileBeIgnored + storagePath string + followSymlinks bool + gitAnnexedSize bool + matchesTimeFilterFn common.TimeFilter + archiveBrowsing bool +} + +// CreateStoredAnalyzer returns Analyzer +func CreateStoredAnalyzer(storagePath string) *StoredAnalyzer { + return &StoredAnalyzer{ + storagePath: storagePath, + progress: &common.CurrentProgress{ + ItemCount: 0, + TotalSize: int64(0), + }, + progressChan: make(chan common.CurrentProgress, 1), + progressOutChan: make(chan common.CurrentProgress, 1), + progressDoneChan: make(chan struct{}), + doneChan: make(common.SignalGroup), + wait: (&WaitGroup{}).Init(), + } +} + +// GetProgressChan returns channel for getting progress +func (a *StoredAnalyzer) GetProgressChan() chan common.CurrentProgress { + return a.progressOutChan +} + +// GetDone returns channel for checking when analysis is done +func (a *StoredAnalyzer) GetDone() common.SignalGroup { + return a.doneChan +} + +func (a *StoredAnalyzer) SetFollowSymlinks(v bool) { + a.followSymlinks = v +} + +func (a *StoredAnalyzer) SetShowAnnexedSize(v bool) { + a.gitAnnexedSize = v +} + +// SetTimeFilter sets the time filter function for file inclusion +func (a *StoredAnalyzer) SetTimeFilter(matchesTimeFilterFn common.TimeFilter) { + a.matchesTimeFilterFn = matchesTimeFilterFn +} + +// SetArchiveBrowsing sets whether browsing of zip/jar archives is enabled +func (a *StoredAnalyzer) SetArchiveBrowsing(v bool) { + a.archiveBrowsing = v +} + +// SetFileTypeFilter sets the file type filter function +func (a *StoredAnalyzer) SetFileTypeFilter(filter common.ShouldFileBeIgnored) { + a.ignoreFileType = filter +} + +// ResetProgress returns progress +func (a *StoredAnalyzer) ResetProgress() { + a.progress = &common.CurrentProgress{} + a.progressChan = make(chan common.CurrentProgress, 1) + a.progressOutChan = make(chan common.CurrentProgress, 1) + a.progressDoneChan = make(chan struct{}) + a.doneChan = make(common.SignalGroup) + a.wait = (&WaitGroup{}).Init() +} + +// AnalyzeDir analyzes given path +func (a *StoredAnalyzer) AnalyzeDir( + path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, +) fs.Item { + a.ignoreDir = ignore + a.ignoreFileType = fileTypeFilter + + a.storage = NewStorage(a.storagePath, path) + closeFn := a.storage.Open() + defer func() { + // nasty hack to close storage after all goroutines are done + // Wait returns immediately if value is 0 + // few last goroutines might still start after that + time.Sleep(1 * time.Second) + closeFn() + }() + + a.ignoreDir = ignore + + go a.updateProgress() + dir := a.processDir(path) + + a.wait.Wait() + + a.progressDoneChan <- struct{}{} + a.doneChan.Broadcast() + + return dir +} + +func (a *StoredAnalyzer) processDir(path string) *StoredDir { + var ( + file fs.Item + err error + totalSize int64 + info os.FileInfo + dirCount int + ) + + a.wait.Add(1) + + files, err := os.ReadDir(path) + if err != nil { + log.Print(err.Error()) + } + + dir := &StoredDir{ + Dir: &Dir{ + File: &File{ + Name: filepath.Base(path), + Flag: getDirFlag(err, len(files)), + }, + BasePath: filepath.Dir(path), + ItemCount: 1, + Files: make(fs.Files, 0, len(files)), + }, + } + parent := &ParentDir{Path: path} + + setDirPlatformSpecificAttrs(dir.Dir, path) + + for _, f := range files { + name := f.Name() + entryPath := filepath.Join(path, name) + if f.IsDir() { + if a.ignoreDir(name, entryPath) { + continue + } + dirCount++ + + subdir := &StoredDir{ + &Dir{ + File: &File{ + Name: name, + }, + BasePath: path, + }, + nil, + sync.Mutex{}, + } + dir.AddFile(subdir) + + go func(entryPath string) { + concurrencyLimit <- struct{}{} + a.processDir(entryPath) + <-concurrencyLimit + }(entryPath) + } else { + info, err = f.Info() + if err != nil { + log.Print(err.Error()) + continue + } + + // Check if it's a zip or jar file + if a.archiveBrowsing && isZipFile(name) { + zipDir, err := processZipFile(entryPath, info) + if err != nil { + // If unable to process zip file, treat as regular file + log.Printf("Failed to process zip file %s: %v", entryPath, err) + file = &File{ + Name: name, + Flag: getFlag(info), + Size: info.Size(), + Parent: parent, + } + } else { + // Successfully processed zip file, use zip content size + uncompressedSize, compressedSize, err := getZipFileSize(entryPath) + if err == nil { + zipDir.Size = uncompressedSize + zipDir.Usage = compressedSize + } + zipDir.Parent = parent + file = zipDir + } + } else { + file = &File{ + Name: name, + Flag: getFlag(info), + Size: info.Size(), + Parent: parent, + } + } + + // Apply time filter if set + if a.matchesTimeFilterFn != nil && !a.matchesTimeFilterFn(info.ModTime()) { + continue // Skip this file + } + + // Apply file type filter if set + if a.ignoreFileType != nil && a.ignoreFileType(name) { + continue // Skip this file + } + + if file != nil { + // Only set platform-specific attributes for regular files + if regularFile, ok := file.(*File); ok { + setPlatformSpecificAttrs(regularFile, info) + } + totalSize += file.GetUsage() + dir.AddFile(file) + } + } + } + + err = a.storage.StoreDir(dir) + if err != nil { + log.Print(err.Error()) + } + + a.progressChan <- common.CurrentProgress{ + CurrentItemName: path, + ItemCount: int64(len(files)), + TotalSize: totalSize, + } + + a.wait.Done() + return dir +} + +func (a *StoredAnalyzer) updateProgress() { + for { + select { + case <-a.progressDoneChan: + return + case progress := <-a.progressChan: + a.progress.CurrentItemName = progress.CurrentItemName + a.progress.ItemCount += progress.ItemCount + a.progress.TotalSize += progress.TotalSize + } + + select { + case a.progressOutChan <- *a.progress: + default: + } + } +} + +// StoredDir implements Dir item stored on disk +type StoredDir struct { + *Dir + cachedFiles fs.Files + dbLock sync.Mutex +} + +// GetParent returns parent dir +func (f *StoredDir) GetParent() fs.Item { + if DefaultStorage.GetTopDir() == f.GetPath() { + return nil + } + + if !DefaultStorage.IsOpen() { + closeFn := DefaultStorage.Open() + defer closeFn() + } + + dir, err := DefaultStorage.GetDirForPath(f.BasePath) + if err != nil { + log.Print(err.Error()) + } + return dir +} + +// GetFiles returns files in directory as a sorted iterator +// If files are already cached, use them +// Otherwise load them from storage +func (f *StoredDir) GetFiles(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { + return func(yield func(fs.Item) bool) { + files := f.loadFiles() + sortFiles(files, sortBy, order) + + for _, item := range files { + if !yield(item) { + return + } + } + } +} + +// loadFiles loads files from storage or returns cached files +func (f *StoredDir) loadFiles() fs.Files { + if f.cachedFiles != nil { + // Return a copy to avoid modifying cached slice + result := make(fs.Files, len(f.cachedFiles)) + copy(result, f.cachedFiles) + return result + } + + if !DefaultStorage.IsOpen() { + f.dbLock.Lock() + defer f.dbLock.Unlock() + closeFn := DefaultStorage.Open() + defer closeFn() + } + + var files fs.Files + for _, file := range f.Files { + if file.IsDir() { + dir := &StoredDir{ + &Dir{ + File: &File{ + Name: file.GetName(), + }, + BasePath: f.GetPath(), + }, + nil, + sync.Mutex{}, + } + + err := DefaultStorage.LoadDir(dir) + if err != nil { + log.Print(err.Error()) + } + files = append(files, dir) + } else { + files = append(files, file) + } + } + + f.cachedFiles = files + // Return a copy to avoid modifying cached slice + result := make(fs.Files, len(files)) + copy(result, files) + return result +} + +// RemoveFile removes file from stored directory +// It also updates size and item count of parent directories +func (f *StoredDir) RemoveFile(item fs.Item) { + if !DefaultStorage.IsOpen() { + f.dbLock.Lock() + defer f.dbLock.Unlock() + closeFn := DefaultStorage.Open() + defer closeFn() + } + + f.Files = f.Files.Remove(item) + f.cachedFiles = nil + + cur := f + for { + cur.ItemCount -= item.GetItemCount() + cur.Size -= item.GetSize() + cur.Usage -= item.GetUsage() + + err := DefaultStorage.StoreDir(cur) + if err != nil { + log.Print(err.Error()) + } + + parent := cur.GetParent() + if parent == nil { + break + } + cur = parent.(*StoredDir) + } +} + +// GetItemStats returns item count, apparent usage and real usage of this dir +func (f *StoredDir) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount, size, usage int64) { + f.UpdateStats(linkedItems) + return f.ItemCount, f.GetSize(), f.GetUsage() +} + +// UpdateStats recursively updates size and item count +func (f *StoredDir) UpdateStats(linkedItems fs.HardLinkedItems) { + if !DefaultStorage.IsOpen() { + closeFn := DefaultStorage.Open() + defer closeFn() + } + + totalSize := int64(4096) + totalUsage := int64(4096) + var itemCount int64 + f.cachedFiles = nil + files := f.loadFiles() + for _, entry := range files { + count, size, usage := entry.GetItemStats(linkedItems) + totalSize += size + totalUsage += usage + itemCount += count + + if entry.GetMtime().After(f.Mtime) { + f.Mtime = entry.GetMtime() + } + + switch entry.GetFlag() { + case '!', '.': + if f.Flag != '!' { + f.Flag = '.' + } + } + } + f.cachedFiles = nil + f.ItemCount = itemCount + 1 + f.Size = totalSize + f.Usage = totalUsage + err := DefaultStorage.StoreDir(f) + if err != nil { + log.Print(err.Error()) + } +} + +// RemoveFileByName removes file by name from stored directory +func (f *StoredDir) RemoveFileByName(name string) { + if !DefaultStorage.IsOpen() { + f.dbLock.Lock() + defer f.dbLock.Unlock() + closeFn := DefaultStorage.Open() + defer closeFn() + } + + idx, ok := f.Files.FindByName(name) + if !ok { + return + } + item := f.Files[idx] + f.Files = append(f.Files[:idx], f.Files[idx+1:]...) + f.cachedFiles = nil + + cur := f + for { + cur.ItemCount -= item.GetItemCount() + cur.Size -= item.GetSize() + cur.Usage -= item.GetUsage() + + err := DefaultStorage.StoreDir(cur) + if err != nil { + log.Print(err.Error()) + } + + parent := cur.GetParent() + if parent == nil { + break + } + cur = parent.(*StoredDir) + } +} + +// ParentDir represents parent directory of single file +// It is used to get path to parent directory of a file +type ParentDir struct { + Path string +} + +func (p *ParentDir) GetPath() string { + return p.Path +} +func (p *ParentDir) GetName() string { panic("must not be called") } +func (p *ParentDir) GetFlag() rune { panic("must not be called") } +func (p *ParentDir) IsDir() bool { panic("must not be called") } +func (p *ParentDir) GetSize() int64 { panic("must not be called") } +func (p *ParentDir) GetType() string { panic("must not be called") } +func (p *ParentDir) GetUsage() int64 { panic("must not be called") } +func (p *ParentDir) GetMtime() time.Time { panic("must not be called") } +func (p *ParentDir) GetItemCount() int64 { panic("must not be called") } +func (p *ParentDir) GetParent() fs.Item { panic("must not be called") } +func (p *ParentDir) SetParent(fs.Item) { panic("must not be called") } +func (p *ParentDir) GetMultiLinkedInode() uint64 { panic("must not be called") } +func (p *ParentDir) EncodeJSON(writer io.Writer, topLevel bool) error { panic("must not be called") } +func (p *ParentDir) UpdateStats(linkedItems fs.HardLinkedItems) { panic("must not be called") } +func (p *ParentDir) AddFile(fs.Item) { panic("must not be called") } +func (p *ParentDir) GetFiles(fs.SortBy, fs.SortOrder) iter.Seq[fs.Item] { panic("must not be called") } +func (p *ParentDir) GetFilesLocked(fs.SortBy, fs.SortOrder) iter.Seq[fs.Item] { + panic("must not be called") +} +func (p *ParentDir) RLock() func() { panic("must not be called") } +func (p *ParentDir) RemoveFile(item fs.Item) { panic("must not be called") } +func (p *ParentDir) RemoveFileByName(name string) { panic("must not be called") } +func (p *ParentDir) GetItemStats( + linkedItems fs.HardLinkedItems, +) (itemCount, size, usage int64) { + panic("must not be called") +} diff --git a/pkg/analyze/stored_coverage_test.go b/pkg/analyze/stored_coverage_test.go new file mode 100644 index 0000000..99ccb77 --- /dev/null +++ b/pkg/analyze/stored_coverage_test.go @@ -0,0 +1,198 @@ +package analyze + +import ( + "os" + "slices" + "testing" + "time" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestStoredAnalyzerGetProgressChan(t *testing.T) { + analyzer := CreateStoredAnalyzer("/tmp/test") + progressChan := analyzer.GetProgressChan() + assert.NotNil(t, progressChan) +} + +func TestStoredAnalyzerSetFollowSymlinks(t *testing.T) { + analyzer := CreateStoredAnalyzer("/tmp/test") + analyzer.SetFollowSymlinks(true) + assert.True(t, analyzer.followSymlinks) + analyzer.SetFollowSymlinks(false) + assert.False(t, analyzer.followSymlinks) +} + +func TestStoredAnalyzerSetShowAnnexedSize(t *testing.T) { + analyzer := CreateStoredAnalyzer("/tmp/test") + analyzer.SetShowAnnexedSize(true) + assert.True(t, analyzer.gitAnnexedSize) + analyzer.SetShowAnnexedSize(false) + assert.False(t, analyzer.gitAnnexedSize) +} + +func TestStoredDirGetFilesCached(t *testing.T) { + // Test when files are already cached + files := make(fs.Files, 0) + dir := &StoredDir{ + Dir: &Dir{ + File: &File{ + Name: "test", + }, + BasePath: "/test", + }, + cachedFiles: files, + } + + result := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Equal(t, len(files), len(result)) +} + +func TestStoredDirRemoveFile(t *testing.T) { + // Test RemoveFile functionality + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateStoredAnalyzer("/tmp/test") + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*StoredDir) + + analyzer.GetDone().Wait() + + // Remove a file + files := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) + if len(files) > 0 { + dir.RemoveFile(files[0]) + } +} + +func TestStoredDirUpdateStats(t *testing.T) { + // Test UpdateStats functionality + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateStoredAnalyzer("/tmp/test") + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*StoredDir) + + analyzer.GetDone().Wait() + + dir.UpdateStats(make(fs.HardLinkedItems)) +} + +func TestStoredDirUpdateStatsWithMtimeUpdate(t *testing.T) { + // Test UpdateStats with mtime updates + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateStoredAnalyzer("/tmp/test") + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*StoredDir) + + analyzer.GetDone().Wait() + + // Create a file with newer mtime + file := &File{ + Name: "newfile", + Mtime: time.Now().Add(time.Hour), + } + dir.AddFile(file) + + dir.UpdateStats(make(fs.HardLinkedItems)) +} + +func TestStoredDirUpdateStatsWithFlagUpdate(t *testing.T) { + // Test UpdateStats with flag updates + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateStoredAnalyzer("/tmp/test") + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*StoredDir) + + analyzer.GetDone().Wait() + + // Create a file with error flag + file := &File{ + Name: "errorfile", + Flag: '!', + } + dir.AddFile(file) + + dir.UpdateStats(make(fs.HardLinkedItems)) + // Just test that UpdateStats runs without error + // The flag behavior depends on the specific implementation +} + +func TestStoredDirUpdateStatsWithDotFlag(t *testing.T) { + // Test UpdateStats with dot flag + fin := testdir.CreateTestDir() + defer fin() + + analyzer := CreateStoredAnalyzer("/tmp/test") + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*StoredDir) + + analyzer.GetDone().Wait() + + // Create a file with dot flag + file := &File{ + Name: "dotfile", + Flag: '.', + } + dir.AddFile(file) + + dir.UpdateStats(make(fs.HardLinkedItems)) + assert.Equal(t, '.', dir.Flag) +} + +func TestStoredAnalyzerWithZip(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + // Create valid zip + createTestZipFile(t, "test_dir/valid.zip") + + // Create invalid zip + f, err := os.Create("test_dir/invalid.zip") + assert.NoError(t, err) + _, err = f.WriteString("this is not a zip file") + assert.NoError(t, err) + f.Close() + + analyzer := CreateStoredAnalyzer("/tmp/test") + analyzer.SetArchiveBrowsing(true) + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*StoredDir) + + analyzer.GetDone().Wait() + + // Check valid.zip + var validZip fs.Item + var invalidZip fs.Item + + for _, file := range dir.Files { + if file.GetName() == "valid.zip" { + validZip = file + } + if file.GetName() == "invalid.zip" { + invalidZip = file + } + } + + assert.NotNil(t, validZip) + assert.True(t, validZip.IsDir()) + assert.Greater(t, validZip.GetSize(), int64(0)) + + assert.NotNil(t, invalidZip) + assert.False(t, invalidZip.IsDir()) + assert.Equal(t, int64(22), invalidZip.GetSize()) +} diff --git a/pkg/analyze/stored_test.go b/pkg/analyze/stored_test.go new file mode 100644 index 0000000..4ec3d39 --- /dev/null +++ b/pkg/analyze/stored_test.go @@ -0,0 +1,310 @@ +package analyze + +import ( + "bytes" + "encoding/gob" + "fmt" + "slices" + "testing" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestEncDec(t *testing.T) { + var d fs.Item = &StoredDir{ + Dir: &Dir{ + File: &File{ + Name: "xxx", + }, + BasePath: "/yyy", + }, + } + + b := &bytes.Buffer{} + enc := gob.NewEncoder(b) + err := enc.Encode(d) + assert.NoError(t, err) + + var x fs.Item = &StoredDir{} + dec := gob.NewDecoder(b) + err = dec.Decode(x) + assert.NoError(t, err) + + fmt.Println(d, x) + assert.Equal(t, d.GetName(), x.GetName()) +} + +func TestStoredAnalyzer(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + a := CreateStoredAnalyzer("/tmp/badger") + dir := a.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*StoredDir) + + a.GetDone().Wait() + + dir.UpdateStats(make(fs.HardLinkedItems)) + + // test dir info + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, int64(7+4096*3), dir.Size) + assert.Equal(t, int64(5), dir.ItemCount) + assert.True(t, dir.IsDir()) + + // test dir tree + files := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Equal(t, "nested", files[0].GetName()) + + nested := files[0].(*StoredDir) + nestedFiles := slices.Collect(nested.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Equal(t, "subnested", nestedFiles[1].GetName()) + + // test file + assert.Equal(t, "file2", nestedFiles[0].GetName()) + assert.Equal(t, int64(2), nestedFiles[0].GetSize()) + assert.True(t, int64(4096) <= nestedFiles[0].GetUsage()) + + subnested := nestedFiles[1].(*StoredDir) + subnestedFiles := slices.Collect(subnested.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Equal(t, "file", subnestedFiles[0].GetName()) + assert.Equal(t, int64(5), subnestedFiles[0].GetSize()) +} + +func TestRemoveStoredFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + a := CreateStoredAnalyzer("/tmp/badger") + dir := a.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*StoredDir) + + a.GetDone().Wait() + a.ResetProgress() + + dir.UpdateStats(make(fs.HardLinkedItems)) + + // test dir info + assert.Equal(t, "test_dir", dir.Name) + assert.Equal(t, int64(7+4096*3), dir.Size) + assert.Equal(t, int64(5), dir.ItemCount) + assert.True(t, dir.IsDir()) + + dirFiles := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) + subdir := dirFiles[0].(*StoredDir) + subdirFiles := slices.Collect(subdir.GetFiles(fs.SortByName, fs.SortAsc)) + subdir.RemoveFile(subdirFiles[0]) + + closeFn := DefaultStorage.Open() + defer closeFn() + stored, err := DefaultStorage.GetDirForPath("test_dir") + assert.NoError(t, err) + + assert.Equal(t, int64(4), stored.GetItemCount()) + assert.Equal(t, int64(5+4096*3), stored.GetSize()) + + storedFiles := slices.Collect(stored.GetFiles(fs.SortByName, fs.SortAsc)) + storedNested := storedFiles[0].(*StoredDir) + storedNestedFiles := slices.Collect(storedNested.GetFiles(fs.SortByName, fs.SortAsc)) + storedSubnested := storedNestedFiles[0].(*StoredDir) + storedSubnestedFiles := slices.Collect(storedSubnested.GetFiles(fs.SortByName, fs.SortAsc)) + file := storedSubnestedFiles[0] + assert.Equal(t, false, file.IsDir()) + assert.Equal(t, "file", file.GetName()) + assert.Equal(t, "test_dir/nested/subnested", file.GetParent().GetPath()) +} + +func TestParentDirGetNamePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetName() +} + +func TestParentDirGetFlagPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetFlag() +} + +func TestParentDirIsDirPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.IsDir() +} + +func TestParentDirGetSizePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetSize() +} + +func TestParentDirGetTypePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetType() +} + +func TestParentDirGetUsagePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetUsage() +} + +func TestParentDirGetMtimePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetMtime() +} + +func TestParentDirGetItemCountPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetItemCount() +} + +func TestParentDirGetParentPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetParent() +} + +func TestParentDirSetParentPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.SetParent(nil) +} + +func TestParentDirGetMultiLinkedInodePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetMultiLinkedInode() +} + +func TestParentDirEncodeJSONPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + err := dir.EncodeJSON(nil, false) + assert.NoError(t, err) +} + +func TestParentDirUpdateStatsPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.UpdateStats(nil) +} + +func TestParentDirAddFilePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.AddFile(nil) +} + +func TestParentDirGetFilesPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetFiles(fs.SortByName, fs.SortAsc) +} + +func TestParentDirGetFilesLockedPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetFilesLocked(fs.SortByName, fs.SortAsc) +} + +func TestParentDirRLockPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.RLock() +} + +func TestParentDirRemoveFilePanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.RemoveFile(nil) +} + +func TestParentDirGetItemStatsPanics(t *testing.T) { + defer func() { + if r := recover(); r != nil { + assert.Equal(t, "must not be called", r) + } + }() + dir := &ParentDir{} + dir.GetItemStats(nil) +} diff --git a/pkg/analyze/symlink.go b/pkg/analyze/symlink.go new file mode 100644 index 0000000..5d7abef --- /dev/null +++ b/pkg/analyze/symlink.go @@ -0,0 +1,40 @@ +package analyze + +import ( + "os" + "path/filepath" + "strings" + + "github.com/dundee/gdu/v5/pkg/annex" +) + +func followSymlink(path string, gitAnnexedSize bool) (tInfo os.FileInfo, err error) { + target, err := filepath.EvalSymlinks(path) + if err != nil { + target, err = os.Readlink(path) + if err != nil { + return nil, err + } + if gitAnnexedSize && strings.Contains(target, ".git/annex/objects") { + tInfo, err = os.Lstat(path) + if err != nil { + return nil, err + } + + name := filepath.Base(target) + tInfo = annex.AnnexedFileInfo(tInfo, name) + return tInfo, nil + } + } + + tInfo, err = os.Lstat(target) + if err != nil { + return nil, err + } + + if tInfo.IsDir() { + return nil, nil + } + + return tInfo, nil +} diff --git a/pkg/analyze/symlink_test.go b/pkg/analyze/symlink_test.go new file mode 100644 index 0000000..7703365 --- /dev/null +++ b/pkg/analyze/symlink_test.go @@ -0,0 +1,42 @@ +package analyze + +import ( + "os" + "testing" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/stretchr/testify/assert" +) + +func TestFollowSymlinkErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Mkdir("test_dir/empty", 0o644) + assert.Nil(t, err) + + err = os.Symlink( + ".git/annex/objects/qx/qX/SHA256E-s967858083--"+ + "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4/SHA256E-s967858083--"+ + "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4", + "test_dir/nested/file3") + assert.Nil(t, err) + + err = os.Symlink( + "test_dir/nested", + "test_dir/some_dir") + assert.Nil(t, err) + + _, err = followSymlink("xxx", false) + assert.ErrorContains(t, err, "no such file or directory") + + _, err = followSymlink("test_dir/nested/file3", false) + assert.ErrorContains(t, err, "no such file or directory") + + _, err = followSymlink("test_dir/nested/file3", true) + assert.NoError(t, err) + + res, err := followSymlink("test_dir/some_dir", true) + assert.Equal(t, nil, res) + assert.NoError(t, err) +} diff --git a/pkg/analyze/top.go b/pkg/analyze/top.go new file mode 100644 index 0000000..227e65f --- /dev/null +++ b/pkg/analyze/top.go @@ -0,0 +1,48 @@ +package analyze + +import ( + "sort" + + "github.com/dundee/gdu/v5/pkg/fs" +) + +// TopList is a list of top largest files +type TopList struct { + Items fs.Files + Count int + MinSize int64 +} + +// NewTopList creates new TopList +func NewTopList(count int) *TopList { + return &TopList{Count: count} +} + +// Add adds file to the list +func (tl *TopList) Add(file fs.Item) { + if file.GetSize() > tl.MinSize || len(tl.Items) < tl.Count { + tl.Items = append(tl.Items, file) + sort.Sort(fs.ByApparentSize(tl.Items)) + if len(tl.Items) > tl.Count { + tl.Items = tl.Items[1:] + } + tl.MinSize = tl.Items[0].GetSize() + } +} + +func CollectTopFiles(dir fs.Item, count int) fs.Files { + topList := NewTopList(count) + walkDir(dir, topList) + sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items))) + return topList.Items +} + +func walkDir(dir fs.Item, topList *TopList) { + for item := range dir.GetFiles(fs.SortBySize, fs.SortDesc) { + if item.IsDir() { + walkDir(item, topList) + } else { + topList.Add(item) + } + } +} diff --git a/pkg/analyze/top_test.go b/pkg/analyze/top_test.go new file mode 100644 index 0000000..3989acc --- /dev/null +++ b/pkg/analyze/top_test.go @@ -0,0 +1,69 @@ +package analyze + +import ( + "sort" + "testing" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestCollectTopFiles2(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := CreateAnalyzer().AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ) + + topFiles := CollectTopFiles(dir, 2) + assert.Equal(t, 2, len(topFiles)) + assert.Equal(t, "file", topFiles[0].GetName()) + assert.Equal(t, int64(5), topFiles[0].GetSize()) + assert.Equal(t, "file2", topFiles[1].GetName()) + assert.Equal(t, int64(2), topFiles[1].GetSize()) +} + +func TestCollectTopFiles1(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := CreateAnalyzer().AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ) + + topFiles := CollectTopFiles(dir, 1) + assert.Equal(t, 1, len(topFiles)) + assert.Equal(t, "file", topFiles[0].GetName()) + assert.Equal(t, int64(5), topFiles[0].GetSize()) +} + +func TestAdd2(t *testing.T) { + topList := NewTopList(2) + topList.Add(&File{Size: 1, Name: "file1"}) + topList.Add(&File{Size: 5, Name: "file5"}) + topList.Add(&File{Size: 2, Name: "file2"}) + + sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items))) + + assert.Equal(t, 2, len(topList.Items)) + assert.Equal(t, "file5", topList.Items[0].GetName()) + assert.Equal(t, "file2", topList.Items[1].GetName()) +} + +func TestAdd3(t *testing.T) { + topList := NewTopList(3) + topList.Add(&File{Size: 5, Name: "file5"}) + topList.Add(&File{Size: 1, Name: "file1"}) + topList.Add(&File{Size: 2, Name: "file2"}) + topList.Add(&File{Size: 4, Name: "file4"}) + topList.Add(&File{Size: 3, Name: "file3"}) + + sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items))) + + assert.Equal(t, 3, len(topList.Items)) + assert.Equal(t, "file5", topList.Items[0].GetName()) + assert.Equal(t, "file4", topList.Items[1].GetName()) + assert.Equal(t, "file3", topList.Items[2].GetName()) +} diff --git a/pkg/analyze/wait.go b/pkg/analyze/wait.go new file mode 100644 index 0000000..f81b577 --- /dev/null +++ b/pkg/analyze/wait.go @@ -0,0 +1,49 @@ +package analyze + +import "sync" + +// A WaitGroup waits for a collection of goroutines to finish. +// In contrast to sync.WaitGroup Add method can be called from a goroutine. +type WaitGroup struct { + wait sync.Mutex + value int + access sync.Mutex +} + +// Init prepares the WaitGroup for usage, locks +func (s *WaitGroup) Init() *WaitGroup { + s.wait.Lock() + return s +} + +// Add increments value +func (s *WaitGroup) Add(value int) { + s.access.Lock() + s.value += value + s.access.Unlock() +} + +// Done decrements the value by one, if value is 0, lock is released +func (s *WaitGroup) Done() { + s.access.Lock() + s.value-- + s.check() + s.access.Unlock() +} + +// Wait blocks until value is 0 +func (s *WaitGroup) Wait() { + s.access.Lock() + isValue := s.value > 0 + s.access.Unlock() + if isValue { + s.wait.Lock() + } +} + +func (s *WaitGroup) check() { + if s.value == 0 { + s.wait.TryLock() + s.wait.Unlock() + } +} diff --git a/pkg/analyze/zipdir.go b/pkg/analyze/zipdir.go new file mode 100644 index 0000000..02be22a --- /dev/null +++ b/pkg/analyze/zipdir.go @@ -0,0 +1,196 @@ +package analyze + +import ( + "archive/zip" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/dundee/gdu/v5/pkg/fs" +) + +// ZipDir represents a directory structure inside a zip file +type ZipDir struct { + *Dir + zipPath string // path to the original zip file +} + +// ZipFile represents a file inside a zip archive +type ZipFile struct { + *File + zipPath string + inZipPath string // path inside the zip file +} + +// GetPath returns the virtual path for zip file +func (zf *ZipFile) GetPath() string { + return zf.zipPath + "/" + zf.inZipPath +} + +// GetType returns type of zip file +func (zf *ZipFile) GetType() string { + return "ZipFile" +} + +// EncodeJSON encodes zip file to JSON +func (zf *ZipFile) EncodeJSON(writer io.Writer, topLevel bool) error { + // Use the embedded File's EncodeJSON method + return zf.File.EncodeJSON(writer, topLevel) +} + +// GetType returns type of zip directory +func (zd *ZipDir) GetType() string { + return "ZipDirectory" +} + +// IsDir returns true for ZipDir +func (zd *ZipDir) IsDir() bool { + return true +} + +// EncodeJSON encodes zip directory to JSON +func (zd *ZipDir) EncodeJSON(writer io.Writer, topLevel bool) error { + // Use the embedded Dir's EncodeJSON method + return zd.Dir.EncodeJSON(writer, topLevel) +} + +// GetPath returns the virtual path for zip directory +func (zd *ZipDir) GetPath() string { + if zd.Parent != nil { + return filepath.Join(zd.Parent.GetPath(), zd.Name) + } + return zd.zipPath +} + +// isZipFile checks if a file is a zip or jar file +func isZipFile(filename string) bool { + ext := strings.ToLower(filepath.Ext(filename)) + return ext == ".zip" || ext == ".jar" +} + +// processZipFile processes a zip file and returns a ZipDir representing its contents +func processZipFile(zipPath string, info os.FileInfo) (zipDir *ZipDir, err error) { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return nil, err + } + defer reader.Close() + + // Create root directory + zipDir = &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: filepath.Base(zipPath), + Flag: 'Z', // Use 'Z' to identify zip files + Size: info.Size(), + Usage: info.Size(), + Mtime: info.ModTime(), + }, + ItemCount: 1, + Files: make(fs.Files, 0), + }, + zipPath: zipPath, + } + + // Use map to store directory structure + dirMap := make(map[string]*ZipDir) + dirMap[""] = zipDir // root directory + + for _, f := range reader.File { + if f.FileInfo().IsDir() { + continue // Skip directory entries, we'll create them automatically based on file paths + } + + // Parse file path and ensure all parent directories exist + dirPath := filepath.Dir(f.Name) + if dirPath == "." { + dirPath = "" // root directory + } + ensureZipDirExists(dirMap, dirPath, zipPath, zipDir) + + // Create file item + parentDir := dirMap[dirPath] + zipFile := &ZipFile{ + File: &File{ + Name: filepath.Base(f.Name), + Flag: ' ', + Size: int64(f.UncompressedSize64), + Usage: int64(f.CompressedSize64), + Mtime: f.FileInfo().ModTime(), + Parent: parentDir, + }, + zipPath: zipPath, + inZipPath: f.Name, + } + + parentDir.AddFile(zipFile) + } + + return zipDir, nil +} + +// ensureZipDirExists ensures all directories in the specified path exist +func ensureZipDirExists(dirMap map[string]*ZipDir, path, zipPath string, rootDir *ZipDir) { + if path == "" || path == "." { + return + } + + // If directory already exists, return directly + if _, exists := dirMap[path]; exists { + return + } + + // Ensure parent directory exists + parentPath := filepath.Dir(path) + if parentPath != "." && parentPath != "" { + ensureZipDirExists(dirMap, parentPath, zipPath, rootDir) + } + + // Create current directory + var parent *ZipDir + if parentPath == "" || parentPath == "." { + parent = rootDir + } else { + parent = dirMap[parentPath] + } + + newDir := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: filepath.Base(path), + Flag: 'Z', + Size: 4096, // virtual directory size + Usage: 4096, + Mtime: time.Now(), + Parent: parent, + }, + ItemCount: 1, + Files: make(fs.Files, 0), + }, + zipPath: zipPath, + } + + dirMap[path] = newDir + parent.AddFile(newDir) +} + +// getZipFileSize gets the total uncompressed size of a zip file +func getZipFileSize(zipPath string) (uncompressed, compressed int64, err error) { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return 0, 0, err + } + defer reader.Close() + + var uncompressedSize, compressedSize int64 + for _, f := range reader.File { + if !f.FileInfo().IsDir() { + uncompressedSize += int64(f.UncompressedSize64) + compressedSize += int64(f.CompressedSize64) + } + } + + return uncompressedSize, compressedSize, nil +} diff --git a/pkg/analyze/zipdir_coverage_test.go b/pkg/analyze/zipdir_coverage_test.go new file mode 100644 index 0000000..9f41466 --- /dev/null +++ b/pkg/analyze/zipdir_coverage_test.go @@ -0,0 +1,382 @@ +package analyze + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZipFileGetPath(t *testing.T) { + zipFile := &ZipFile{ + zipPath: "/path/to/archive.zip", + inZipPath: "folder/file.txt", + } + + path := zipFile.GetPath() + assert.Equal(t, "/path/to/archive.zip/folder/file.txt", path) +} + +func TestZipFileEncodeJSON(t *testing.T) { + zipFile := &ZipFile{ + File: &File{ + Name: "test.txt", + Size: 100, + }, + zipPath: "/path/to/archive.zip", + inZipPath: "test.txt", + } + + var buf bytes.Buffer + err := zipFile.EncodeJSON(&buf, false) + assert.NoError(t, err) + assert.NotEmpty(t, buf.String()) +} + +func TestZipDirEncodeJSON(t *testing.T) { + zipDir := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: "folder", + }, + }, + zipPath: "/path/to/archive.zip", + } + + var buf bytes.Buffer + err := zipDir.EncodeJSON(&buf, false) + assert.NoError(t, err) + assert.NotEmpty(t, buf.String()) +} + +func TestZipDirGetPathWithParent(t *testing.T) { + parent := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: "parent", + }, + }, + zipPath: "/path/to/archive.zip", + } + + zipDir := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: "child", + }, + }, + zipPath: "/path/to/archive.zip", + } + zipDir.Parent = parent + + path := zipDir.GetPath() + assert.Equal(t, filepath.Join(parent.GetPath(), "child"), path) +} + +func TestZipDirGetPathWithoutParent(t *testing.T) { + zipDir := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: "root", + }, + }, + zipPath: "/path/to/archive.zip", + } + + path := zipDir.GetPath() + assert.Equal(t, "/path/to/archive.zip", path) +} + +func TestProcessZipFileWithEmptyZip(t *testing.T) { + // Create a temporary zip file + zipPath := "/tmp/empty.zip" + defer os.Remove(zipPath) + + // Create an empty zip file + file, err := os.Create(zipPath) + assert.NoError(t, err) + file.Close() + + // Create a zip file with no entries + zipFile, err := os.Create(zipPath) + assert.NoError(t, err) + writer := zip.NewWriter(zipFile) + writer.Close() + zipFile.Close() + + info, err := os.Stat(zipPath) + assert.NoError(t, err) + + zipDir, err := processZipFile(zipPath, info) + assert.NoError(t, err) + assert.NotNil(t, zipDir) + assert.Equal(t, "empty.zip", zipDir.Name) + assert.Equal(t, 'Z', zipDir.Flag) +} + +func TestProcessZipFileWithDirectoryEntries(t *testing.T) { + // Create a temporary zip file + zipPath := "/tmp/dir_entries.zip" + defer os.Remove(zipPath) + + // Create a zip file with directory entries + zipFile, err := os.Create(zipPath) + assert.NoError(t, err) + writer := zip.NewWriter(zipFile) + + // Add a directory entry + _, err = writer.Create("folder/") + assert.NoError(t, err) + + // Add a file in the directory + fileWriter, err := writer.Create("folder/file.txt") + assert.NoError(t, err) + fileWriter.Write([]byte("test content")) + + writer.Close() + zipFile.Close() + + info, err := os.Stat(zipPath) + assert.NoError(t, err) + + zipDir, err := processZipFile(zipPath, info) + assert.NoError(t, err) + assert.NotNil(t, zipDir) + assert.Equal(t, "dir_entries.zip", zipDir.Name) +} + +func TestProcessZipFileWithNestedDirectories(t *testing.T) { + // Create a temporary zip file + zipPath := "/tmp/nested.zip" + defer os.Remove(zipPath) + + // Create a zip file with nested directories + zipFile, err := os.Create(zipPath) + assert.NoError(t, err) + writer := zip.NewWriter(zipFile) + + // Add files in nested directories + fileWriter, err := writer.Create("level1/level2/file.txt") + assert.NoError(t, err) + fileWriter.Write([]byte("nested content")) + + writer.Close() + zipFile.Close() + + info, err := os.Stat(zipPath) + assert.NoError(t, err) + + zipDir, err := processZipFile(zipPath, info) + assert.NoError(t, err) + assert.NotNil(t, zipDir) + assert.Equal(t, "nested.zip", zipDir.Name) +} + +func TestProcessZipFileWithRootFiles(t *testing.T) { + // Create a temporary zip file + zipPath := "/tmp/root_files.zip" + defer os.Remove(zipPath) + + // Create a zip file with files in root + zipFile, err := os.Create(zipPath) + assert.NoError(t, err) + writer := zip.NewWriter(zipFile) + + // Add files in root directory + fileWriter, err := writer.Create("file1.txt") + assert.NoError(t, err) + fileWriter.Write([]byte("file1 content")) + + fileWriter, err = writer.Create("file2.txt") + assert.NoError(t, err) + fileWriter.Write([]byte("file2 content")) + + writer.Close() + zipFile.Close() + + info, err := os.Stat(zipPath) + assert.NoError(t, err) + + zipDir, err := processZipFile(zipPath, info) + assert.NoError(t, err) + assert.NotNil(t, zipDir) + assert.Equal(t, "root_files.zip", zipDir.Name) +} + +func TestProcessZipFileError(t *testing.T) { + // Test with non-existent file + zipDir, err := processZipFile("/non/existent/file.zip", nil) + assert.Error(t, err) + assert.Nil(t, zipDir) +} + +func TestGetZipFileSizeWithEmptyZip(t *testing.T) { + // Create a temporary zip file + zipPath := "/tmp/empty_size.zip" + defer os.Remove(zipPath) + + // Create an empty zip file + zipFile, err := os.Create(zipPath) + assert.NoError(t, err) + writer := zip.NewWriter(zipFile) + writer.Close() + zipFile.Close() + + uncompressed, compressed, err := getZipFileSize(zipPath) + assert.NoError(t, err) + assert.Equal(t, int64(0), uncompressed) + assert.Equal(t, int64(0), compressed) +} + +func TestGetZipFileSizeWithFiles(t *testing.T) { + // Create a temporary zip file + zipPath := "/tmp/size_test.zip" + defer os.Remove(zipPath) + + // Create a zip file with files + zipFile, err := os.Create(zipPath) + assert.NoError(t, err) + writer := zip.NewWriter(zipFile) + + // Add a file + fileWriter, err := writer.Create("test.txt") + assert.NoError(t, err) + fileWriter.Write([]byte("test content")) + + writer.Close() + zipFile.Close() + + uncompressed, compressed, err := getZipFileSize(zipPath) + assert.NoError(t, err) + assert.Greater(t, uncompressed, int64(0)) + assert.Greater(t, compressed, int64(0)) +} + +func TestGetZipFileSizeWithDirectories(t *testing.T) { + // Create a temporary zip file + zipPath := "/tmp/dir_size.zip" + defer os.Remove(zipPath) + + // Create a zip file with directories + zipFile, err := os.Create(zipPath) + assert.NoError(t, err) + writer := zip.NewWriter(zipFile) + + // Add a directory entry (should be ignored) + _, err = writer.Create("folder/") + assert.NoError(t, err) + + // Add a file + fileWriter, err := writer.Create("file.txt") + assert.NoError(t, err) + fileWriter.Write([]byte("test content")) + + writer.Close() + zipFile.Close() + + uncompressed, compressed, err := getZipFileSize(zipPath) + assert.NoError(t, err) + assert.Greater(t, uncompressed, int64(0)) + assert.Greater(t, compressed, int64(0)) +} + +func TestGetZipFileSizeError(t *testing.T) { + // Test with non-existent file + uncompressed, compressed, err := getZipFileSize("/non/existent/file.zip") + assert.Error(t, err) + assert.Equal(t, int64(0), uncompressed) + assert.Equal(t, int64(0), compressed) +} + +func TestEnsureZipDirExistsWithEmptyPath(t *testing.T) { + dirMap := make(map[string]*ZipDir) + rootDir := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: "root", + }, + }, + zipPath: "/test.zip", + } + + ensureZipDirExists(dirMap, "", "/test.zip", rootDir) + // Should not create any new directories for empty path + assert.Len(t, dirMap, 0) +} + +func TestEnsureZipDirExistsWithDotPath(t *testing.T) { + dirMap := make(map[string]*ZipDir) + rootDir := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: "root", + }, + }, + zipPath: "/test.zip", + } + + ensureZipDirExists(dirMap, ".", "/test.zip", rootDir) + // Should not create any new directories for dot path + assert.Len(t, dirMap, 0) +} + +func TestEnsureZipDirExistsWithExistingPath(t *testing.T) { + dirMap := make(map[string]*ZipDir) + existingDir := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: "existing", + }, + }, + zipPath: "/test.zip", + } + dirMap["existing"] = existingDir + + rootDir := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: "root", + }, + }, + zipPath: "/test.zip", + } + + ensureZipDirExists(dirMap, "existing", "/test.zip", rootDir) + // Should not create a new directory for existing path + assert.Len(t, dirMap, 1) + assert.Equal(t, existingDir, dirMap["existing"]) +} + +func TestEnsureZipDirExistsWithNestedPath(t *testing.T) { + dirMap := make(map[string]*ZipDir) + rootDir := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: "root", + }, + }, + zipPath: "/test.zip", + } + dirMap[""] = rootDir + + ensureZipDirExists(dirMap, "level1/level2", "/test.zip", rootDir) + + // Should create both level1 and level1/level2 directories + assert.Contains(t, dirMap, "level1") + assert.Contains(t, dirMap, "level1/level2") + assert.Equal(t, "level1", dirMap["level1"].Name) + assert.Equal(t, "level2", dirMap["level1/level2"].Name) +} + +func TestIsZipFileFunction(t *testing.T) { + assert.True(t, isZipFile("test.zip")) + assert.True(t, isZipFile("test.ZIP")) + assert.True(t, isZipFile("test.jar")) + assert.True(t, isZipFile("test.JAR")) + assert.False(t, isZipFile("test.txt")) + assert.False(t, isZipFile("test.tar")) + assert.False(t, isZipFile("test.gz")) +} diff --git a/pkg/analyze/zipdir_integration_test.go b/pkg/analyze/zipdir_integration_test.go new file mode 100644 index 0000000..f2f4b41 --- /dev/null +++ b/pkg/analyze/zipdir_integration_test.go @@ -0,0 +1,182 @@ +package analyze + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestSequentialAnalyzerWithZipFile(t *testing.T) { + // Create temporary directory and zip file + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "test.zip") + + // Create test zip file + createTestZipFile(t, zipPath) + + // Create analyzer + analyzer := CreateSeqAnalyzer() + analyzer.SetArchiveBrowsing(true) + + // Analyze directory (containing zip file) + result := analyzer.AnalyzeDir(tempDir, func(string, string) bool { return false }, func(string) bool { return false }) + + // Verify result + assert.NotNil(t, result) + assert.True(t, result.IsDir()) + + // Find zip file + var zipItem fs.Item + for file := range result.GetFiles(fs.SortByName, fs.SortAsc) { + if file.GetName() == "test.zip" { + zipItem = file + break + } + } + + assert.NotNil(t, zipItem, "should find zip file") + assert.True(t, zipItem.IsDir(), "zip file should be treated as directory") + + // Verify zip file content + zipFilesCount := 0 + foundTextFile := false + for file := range zipItem.GetFiles(fs.SortByName, fs.SortAsc) { + zipFilesCount++ + if file.GetName() == "test.txt" { + foundTextFile = true + assert.False(t, file.IsDir()) + } + } + assert.Greater(t, zipFilesCount, 0, "zip file should contain content") + assert.True(t, foundTextFile, "should find test.txt in zip file") +} + +func TestParallelAnalyzerWithZipFile(t *testing.T) { + // Create temporary directory and zip file + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "test.jar") // test jar file + + // Create test jar file (actually a zip file) + createTestZipFile(t, zipPath) + + // Create parallel analyzer + analyzer := CreateAnalyzer() + analyzer.SetArchiveBrowsing(true) + + // Analyze directory + result := analyzer.AnalyzeDir(tempDir, func(string, string) bool { return false }, func(string) bool { return false }) + + // Verify result + assert.NotNil(t, result) + assert.True(t, result.IsDir()) + + // Find jar file + var jarItem fs.Item + for file := range result.GetFiles(fs.SortByName, fs.SortAsc) { + if file.GetName() == "test.jar" { + jarItem = file + break + } + } + + assert.NotNil(t, jarItem, "should find jar file") + assert.True(t, jarItem.IsDir(), "jar file should be treated as directory") + + // Verify jar file content + jarFilesCount := 0 + for range jarItem.GetFiles(fs.SortByName, fs.SortAsc) { + jarFilesCount++ + } + assert.Greater(t, jarFilesCount, 0, "jar file should contain content") +} + +func TestZipFileWithNestedStructure(t *testing.T) { + // Create temporary directory + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "nested.zip") + + // Create zip file with complex nested structure + createComplexZipFile(t, zipPath) + + // Create analyzer + analyzer := CreateSeqAnalyzer() + analyzer.SetArchiveBrowsing(true) + + // Analyze directory + result := analyzer.AnalyzeDir(tempDir, func(string, string) bool { return false }, func(string) bool { return false }) + + // Find zip file + var zipItem fs.Item + for file := range result.GetFiles(fs.SortByName, fs.SortAsc) { + if file.GetName() == "nested.zip" { + zipItem = file + break + } + } + + assert.NotNil(t, zipItem) + + // Find deeply nested directory + var level1Dir fs.Item + for file := range zipItem.GetFiles(fs.SortByName, fs.SortAsc) { + if file.GetName() == "level1" && file.IsDir() { + level1Dir = file + break + } + } + assert.NotNil(t, level1Dir, "should find level1 directory") + + // Find level2 directory + var level2Dir fs.Item + for file := range level1Dir.GetFiles(fs.SortByName, fs.SortAsc) { + if file.GetName() == "level2" && file.IsDir() { + level2Dir = file + break + } + } + assert.NotNil(t, level2Dir, "should find level2 directory") + + // Find deepest nested file + foundDeepFile := false + for file := range level2Dir.GetFiles(fs.SortByName, fs.SortAsc) { + if file.GetName() == "deep.txt" { + foundDeepFile = true + break + } + } + assert.True(t, foundDeepFile, "should find deeply nested file") +} + +// createComplexZipFile creates a zip file with complex nested structure +func createComplexZipFile(t *testing.T, zipPath string) { + file, err := os.Create(zipPath) + assert.NoError(t, err) + defer file.Close() + + zipWriter := zip.NewWriter(file) + defer zipWriter.Close() + + // Create multi-level nested structure + files := []struct { + name string + content string + }{ + {"root.txt", "Root level file"}, + {"level1/file1.txt", "Level 1 file"}, + {"level1/level2/file2.txt", "Level 2 file"}, + {"level1/level2/deep.txt", "Deep nested file"}, + {"level1/level2/level3/file3.txt", "Level 3 file"}, + {"another/path/file.txt", "Another path file"}, + } + + for _, f := range files { + writer, err := zipWriter.Create(f.name) + assert.NoError(t, err) + _, err = writer.Write([]byte(f.content)) + assert.NoError(t, err) + } +} diff --git a/pkg/analyze/zipdir_test.go b/pkg/analyze/zipdir_test.go new file mode 100644 index 0000000..eaa1fd3 --- /dev/null +++ b/pkg/analyze/zipdir_test.go @@ -0,0 +1,174 @@ +package analyze + +import ( + "archive/zip" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestIsZipFile(t *testing.T) { + tests := []struct { + filename string + expected bool + }{ + {"test.zip", true}, + {"test.jar", true}, + {"TEST.ZIP", true}, + {"TEST.JAR", true}, + {"test.txt", false}, + {"test.tar.gz", false}, + {"test", false}, + {"", false}, + } + + for _, test := range tests { + result := isZipFile(test.filename) + assert.Equal(t, test.expected, result, "filename: %s", test.filename) + } +} + +func TestProcessZipFile(t *testing.T) { + // Create temporary zip file + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "test.zip") + + // Create zip file + createTestZipFile(t, zipPath) + + // Get file info + info, err := os.Stat(zipPath) + assert.NoError(t, err) + + // Process zip file + zipDir, err := processZipFile(zipPath, info) + assert.NoError(t, err) + assert.NotNil(t, zipDir) + + // Verify zip directory properties + assert.Equal(t, "test.zip", zipDir.GetName(), "Name must include extension") + assert.Equal(t, rune('Z'), zipDir.GetFlag()) + assert.True(t, zipDir.IsDir()) + assert.Equal(t, "ZipDirectory", zipDir.GetType()) + + // Verify file structure + files := slices.Collect(zipDir.GetFiles(fs.SortByName, fs.SortAsc)) + assert.Greater(t, len(files), 0) + + // Debug: print all files + t.Logf("Found %d files in zip:", len(files)) + for _, file := range files { + t.Logf(" - %s (isDir: %t, type: %s)", file.GetName(), file.IsDir(), file.GetType()) + } + + // Find files + foundTextFile := false + foundSubdir := false + + for _, file := range files { + if file.GetName() == "test.txt" { + foundTextFile = true + assert.False(t, file.IsDir()) + assert.Equal(t, "ZipFile", file.GetType()) + } + if file.GetName() == "subdir" { + foundSubdir = true + assert.True(t, file.IsDir()) + assert.Equal(t, "ZipDirectory", file.GetType()) + } + } + + assert.True(t, foundTextFile, "should find test.txt file") + assert.True(t, foundSubdir, "should find subdir directory") +} + +func TestGetZipFileSize(t *testing.T) { + // Create temporary zip file + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "test.zip") + + // Create zip file + createTestZipFile(t, zipPath) + + // Get size + uncompressed, compressed, err := getZipFileSize(zipPath) + assert.NoError(t, err) + assert.Greater(t, uncompressed, int64(0)) + assert.Greater(t, compressed, int64(0)) + // Note: for small files, compressed size might be larger + t.Logf("Uncompressed size: %d, Compressed size: %d", uncompressed, compressed) +} + +func TestEnsureZipDirExists(t *testing.T) { + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "test.zip") + + // Create root directory + rootDir := &ZipDir{ + Dir: &Dir{ + File: &File{ + Name: "test.zip", + Flag: 'Z', + }, + Files: make(fs.Files, 0), + }, + zipPath: zipPath, + } + + dirMap := make(map[string]*ZipDir) + dirMap[""] = rootDir + + // Ensure nested directory structure is created + ensureZipDirExists(dirMap, "dir1/dir2/dir3", zipPath, rootDir) + + // Verify directory structure + assert.Contains(t, dirMap, "dir1") + assert.Contains(t, dirMap, "dir1/dir2") + assert.Contains(t, dirMap, "dir1/dir2/dir3") + + // Verify parent-child relationships + dir1 := dirMap["dir1"] + assert.Equal(t, rootDir, dir1.GetParent()) + + dir2 := dirMap["dir1/dir2"] + assert.Equal(t, dir1, dir2.GetParent()) + + dir3 := dirMap["dir1/dir2/dir3"] + assert.Equal(t, dir2, dir3.GetParent()) +} + +// createTestZipFile creates a test zip file +func createTestZipFile(t *testing.T, zipPath string) { + file, err := os.Create(zipPath) + assert.NoError(t, err) + defer file.Close() + + zipWriter := zip.NewWriter(file) + defer zipWriter.Close() + + // Add root directory file + writer, err := zipWriter.Create("test.txt") + assert.NoError(t, err) + _, err = writer.Write([]byte("Hello, this is a test file!")) + assert.NoError(t, err) + + // Add subdirectory files + // We don't need to use the writer for the directory entry, avoid SA4006 + _, err = zipWriter.Create("subdir/") + assert.NoError(t, err) + + writer, err = zipWriter.Create("subdir/nested.txt") + assert.NoError(t, err) + _, err = writer.Write([]byte("This is a nested file.")) + assert.NoError(t, err) + + // Add deeper directory structure + writer, err = zipWriter.Create("dir1/dir2/deep.txt") + assert.NoError(t, err) + _, err = writer.Write([]byte("Deep nested file content.")) + assert.NoError(t, err) +} diff --git a/pkg/annex/annex.go b/pkg/annex/annex.go new file mode 100644 index 0000000..0e75a8d --- /dev/null +++ b/pkg/annex/annex.go @@ -0,0 +1,65 @@ +package annex + +import ( + "fmt" + "io/fs" + "log" + "strconv" + "strings" +) + +// SizeFromKey returns size from git-annex key. +func SizeFromKey(name string) (size int64, err error) { + nameParts := strings.SplitN(name, "--", 2) + backendKVs := nameParts[0] + backendKVParts := strings.Split(backendKVs, "-") + + if len(backendKVParts) < 2 { + return 0, fmt.Errorf("key is is missing backend") + } + + for _, p := range backendKVParts[1:] { + if p == "" || p[0] != 's' { + continue + } + + size, err = strconv.ParseInt(p[1:], 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse size: %w", err) + } + + return size, nil + } + + return 0, fmt.Errorf("size not found in key") +} + +// AnnexedFileInfo returns a new FileInfo with size from git-annex key. +func AnnexedFileInfo(fi fs.FileInfo, name string) *FileInfo { + size, err := SizeFromKey(name) + if err != nil { + log.Print(err.Error()) + return &FileInfo{FileInfo: fi} + } + + afi := &FileInfo{ + FileInfo: fi, + size: size, + } + + return afi +} + +var _ fs.FileInfo = (*FileInfo)(nil) + +// FileInfo is a wrapper around fs.FileInfo to overwrite the size. +type FileInfo struct { + fs.FileInfo + + size int64 +} + +// Length in bytes for regular files; system-dependent for others +func (fi *FileInfo) Size() int64 { + return int64(fi.size) +} diff --git a/pkg/annex/annex_test.go b/pkg/annex/annex_test.go new file mode 100644 index 0000000..5b8c072 --- /dev/null +++ b/pkg/annex/annex_test.go @@ -0,0 +1,39 @@ +package annex + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAnnexedFileInfo(t *testing.T) { + fi := &FileInfo{} + fi = AnnexedFileInfo(fi, "SHA256E-s967858083--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") + + assert.Equal(t, int64(967858083), fi.Size()) +} + +func TestAnnexedFileInfoErr(t *testing.T) { + fi := &FileInfo{} + fi = AnnexedFileInfo(fi, "xxx") + + assert.Equal(t, int64(0), fi.Size()) +} + +func TestSizeFromKeyErr(t *testing.T) { + _, err := SizeFromKey("xxx") + assert.Error(t, err) + assert.ErrorContains(t, err, "key is is missing backend") + + _, err = SizeFromKey("SHA256E-sXXX--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to parse size") + + _, err = SizeFromKey("SHA256E-s--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to parse size") + + _, err = SizeFromKey("SHA256E-a-b-c--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") + assert.Error(t, err) + assert.ErrorContains(t, err, "size not found in key") +} diff --git a/pkg/device/dev.go b/pkg/device/dev.go new file mode 100644 index 0000000..21991ca --- /dev/null +++ b/pkg/device/dev.go @@ -0,0 +1,56 @@ +package device + +import "strings" + +// Device struct +type Device struct { + Name string + MountPoint string + Fstype string + Size int64 + Free int64 +} + +// GetUsage returns used size of device +func (d Device) GetUsage() int64 { + return d.Size - d.Free +} + +// DevicesInfoGetter is type for GetDevicesInfo function +type DevicesInfoGetter interface { + GetMounts() (Devices, error) + GetDevicesInfo() (Devices, error) +} + +// Devices if slice of Device items +type Devices []*Device + +// ByUsedSize sorts devices by used size +type ByUsedSize Devices + +func (f ByUsedSize) Len() int { return len(f) } +func (f ByUsedSize) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByUsedSize) Less(i, j int) bool { + return f[i].GetUsage() < f[j].GetUsage() +} + +// ByName sorts devices by device name +type ByName Devices + +func (f ByName) Len() int { return len(f) } +func (f ByName) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByName) Less(i, j int) bool { + return f[i].Name < f[j].Name +} + +// GetNestedMountpointsPaths returns paths of nested mount points +func GetNestedMountpointsPaths(path string, mounts Devices) []string { + paths := make([]string, 0, len(mounts)) + + for _, mount := range mounts { + if strings.HasPrefix(mount.MountPoint, path) && mount.MountPoint != path { + paths = append(paths, mount.MountPoint) + } + } + return paths +} diff --git a/pkg/device/dev_bsd.go b/pkg/device/dev_bsd.go new file mode 100644 index 0000000..386914a --- /dev/null +++ b/pkg/device/dev_bsd.go @@ -0,0 +1,72 @@ +//go:build netbsd || openbsd + +package device + +import ( + "bufio" + "bytes" + "errors" + "io" + "os/exec" + "regexp" + "strings" +) + +// BSDDevicesInfoGetter returns info for Darwin devices +type BSDDevicesInfoGetter struct { + MountCmd string +} + +// Getter is current instance of DevicesInfoGetter +var Getter DevicesInfoGetter = BSDDevicesInfoGetter{MountCmd: "/sbin/mount"} + +// GetMounts returns all mounted filesystems from output of /sbin/mount +func (t BSDDevicesInfoGetter) GetMounts() (devices Devices, err error) { + out, err := exec.Command(t.MountCmd).Output() + if err != nil { + return nil, err + } + + rdr := bytes.NewReader(out) + + return readMountOutput(rdr) +} + +// GetDevicesInfo returns result of GetMounts with usage info about mounted devices (by calling Statfs syscall) +func (t BSDDevicesInfoGetter) GetDevicesInfo() (devices Devices, err error) { + mounts, err := t.GetMounts() + if err != nil { + return nil, err + } + + return processMounts(mounts, false) +} + +func readMountOutput(rdr io.Reader) (mounts Devices, err error) { + scanner := bufio.NewScanner(rdr) + for scanner.Scan() { + line := scanner.Text() + + re := regexp.MustCompile("^(.*) on (/.*) type (.*) \\(([^)]+)\\)$") + parts := re.FindAllStringSubmatch(line, -1) + + if len(parts) < 1 { + return nil, errors.New("Cannot parse mount output") + } + + fstype := strings.TrimSpace(strings.Split(parts[0][3], ",")[0]) + + device := &Device{ + Name: parts[0][1], + MountPoint: parts[0][2], + Fstype: fstype, + } + mounts = append(mounts, device) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return mounts, nil +} diff --git a/pkg/device/dev_bsd_test.go b/pkg/device/dev_bsd_test.go new file mode 100644 index 0000000..81c0a8d --- /dev/null +++ b/pkg/device/dev_bsd_test.go @@ -0,0 +1,21 @@ +//go:build freebsd || openbsd || netbsd || darwin + +package device + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetDevicesInfo(t *testing.T) { + getter := BSDDevicesInfoGetter{MountCmd: "/sbin/mount"} + devices, _ := getter.GetDevicesInfo() + assert.IsType(t, Devices{}, devices) +} + +func TestGetDevicesInfoFail(t *testing.T) { + getter := BSDDevicesInfoGetter{MountCmd: "/nonexistent"} + _, err := getter.GetDevicesInfo() + assert.Equal(t, "fork/exec /nonexistent: no such file or directory", err.Error()) +} diff --git a/pkg/device/dev_freebsd_darwin_other.go b/pkg/device/dev_freebsd_darwin_other.go new file mode 100644 index 0000000..22f533a --- /dev/null +++ b/pkg/device/dev_freebsd_darwin_other.go @@ -0,0 +1,97 @@ +//go:build freebsd || darwin + +package device + +import ( + "bufio" + "bytes" + "errors" + "io" + "os/exec" + "regexp" + "strings" + + "golang.org/x/sys/unix" +) + +// BSDDevicesInfoGetter returns info for Darwin devices +type BSDDevicesInfoGetter struct { + MountCmd string +} + +// Getter is current instance of DevicesInfoGetter +var Getter DevicesInfoGetter = BSDDevicesInfoGetter{MountCmd: "/sbin/mount"} + +// GetMounts returns all mounted filesystems from output of /sbin/mount +func (t BSDDevicesInfoGetter) GetMounts() (devices Devices, err error) { + var out []byte + out, err = exec.Command(t.MountCmd).Output() + if err != nil { + return nil, err + } + + rdr := bytes.NewReader(out) + + return readMountOutput(rdr) +} + +// GetDevicesInfo returns result of GetMounts with usage info about mounted devices (by calling Statfs syscall) +func (t BSDDevicesInfoGetter) GetDevicesInfo() (devices Devices, err error) { + var mounts Devices + mounts, err = t.GetMounts() + if err != nil { + return nil, err + } + + return processMounts(mounts, false) +} + +func readMountOutput(rdr io.Reader) (mounts Devices, err error) { + scanner := bufio.NewScanner(rdr) + for scanner.Scan() { + line := scanner.Text() + + re := regexp.MustCompile(`^(.*) on (/.*) \(([^)]+)\)$`) + parts := re.FindAllStringSubmatch(line, -1) + + if len(parts) < 1 { + return nil, errors.New("cannot parse mount output") + } + + fstype := strings.TrimSpace(strings.Split(parts[0][3], ",")[0]) + + device := &Device{ + Name: parts[0][1], + MountPoint: parts[0][2], + Fstype: fstype, + } + mounts = append(mounts, device) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return mounts, nil +} + +func processMounts(mounts Devices, ignoreErrors bool) (devices Devices, err error) { + for _, mount := range mounts { + if !strings.HasPrefix(mount.Name, "/dev") && mount.Fstype != "zfs" { + continue + } + + info := &unix.Statfs_t{} + err := unix.Statfs(mount.MountPoint, info) + if err != nil && !ignoreErrors { + return nil, err + } + + mount.Size = int64(info.Bsize) * int64(info.Blocks) + mount.Free = int64(info.Bsize) * int64(info.Bavail) + + devices = append(devices, mount) + } + + return devices, nil +} diff --git a/pkg/device/dev_freebsd_darwin_test.go b/pkg/device/dev_freebsd_darwin_test.go new file mode 100644 index 0000000..37a0fa9 --- /dev/null +++ b/pkg/device/dev_freebsd_darwin_test.go @@ -0,0 +1,43 @@ +//go:build freebsd || darwin + +package device + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZfsMountsShown(t *testing.T) { + mounts, _ := readMountOutput(strings.NewReader(`/dev/ada0p2 on / (ufs, local, soft-updates) +devfs on /dev (devfs) +tmpfs on /tmp (tmpfs, local) +fdescfs on /dev/fd (fdescfs) +procfs on /proc (procfs, local) +t on /t (zfs, local, nfsv4acls) +t/db on /t/db (zfs, local, nfsv4acls) +t/vm on /t/vm (zfs, local, nfsv4acls) +t/log/pflog on /var/log/pflog (zfs, local, nfsv4acls) +t/log on /t/log (zfs, local, nfsv4acls) +devfs on /compat/linux/dev (devfs) +fdescfs on /compat/linux/dev/fd (fdescfs) +tmpfs on /compat/linux/dev/shm (tmpfs, local) +map -hosts on /net (autofs) +argon:/usr/src on /usr/src (nfs) +argon:/usr/obj on /usr/obj (nfs)`)) + + devices, err := processMounts(mounts, true) + assert.Len(t, devices, 6) + assert.Nil(t, err) +} + +func TestMountsWithSpace(t *testing.T) { + mounts, err := readMountOutput(strings.NewReader( + `//inglor@vault.lan/volatile on /Users/inglor/Mountpoints/volatile (vault.lan) (smbfs, nodev, nosuid, mounted by inglor)`, + )) + assert.Equal(t, "//inglor@vault.lan/volatile", mounts[0].Name) + assert.Equal(t, "/Users/inglor/Mountpoints/volatile (vault.lan)", mounts[0].MountPoint) + assert.Equal(t, "smbfs", mounts[0].Fstype) + assert.Nil(t, err) +} diff --git a/pkg/device/dev_linux.go b/pkg/device/dev_linux.go new file mode 100644 index 0000000..ff47cfd --- /dev/null +++ b/pkg/device/dev_linux.go @@ -0,0 +1,104 @@ +package device + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/sys/unix" +) + +// LinuxDevicesInfoGetter returns info for Linux devices +type LinuxDevicesInfoGetter struct { + MountsPath string +} + +// Getter is current instance of DevicesInfoGetter +var Getter DevicesInfoGetter = LinuxDevicesInfoGetter{MountsPath: "/proc/mounts"} + +// GetMounts returns all mounted filesystems from /proc/mounts +func (t LinuxDevicesInfoGetter) GetMounts() (devices Devices, err error) { + file, err := os.Open(t.MountsPath) + if err != nil { + return nil, err + } + + devices, err = readMountsFile(file) + if err != nil { + if cerr := file.Close(); cerr != nil { + return nil, fmt.Errorf("%w; %s", err, cerr.Error()) + } + return nil, err + } + if err := file.Close(); err != nil { + return nil, err + } + return devices, nil +} + +// GetDevicesInfo returns result of GetMounts with usage info about mounted devices (by calling Statfs syscall) +func (t LinuxDevicesInfoGetter) GetDevicesInfo() (devices Devices, err error) { + mounts, err := t.GetMounts() + if err != nil { + return nil, err + } + + return processMounts(mounts, false) +} + +func readMountsFile(file io.Reader) (mounts Devices, err error) { + mounts = Devices{} + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + parts := strings.Fields(line) + + device := &Device{ + Name: parts[0], + MountPoint: unescapeString(parts[1]), + Fstype: parts[2], + } + mounts = append(mounts, device) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return mounts, nil +} + +func processMounts(mounts Devices, ignoreErrors bool) (devices Devices, err error) { + devices = Devices{} + + for _, mount := range mounts { + if strings.Contains(mount.MountPoint, "/snap/") { + continue + } + + if strings.HasPrefix(mount.Name, "/dev") || + mount.Fstype == "zfs" || + mount.Fstype == "nfs" || + mount.Fstype == "nfs4" { + info := &unix.Statfs_t{} + err = unix.Statfs(mount.MountPoint, info) + if err != nil && !ignoreErrors { + return nil, err + } + + mount.Size = int64(info.Bsize) * int64(info.Blocks) + mount.Free = int64(info.Bsize) * int64(info.Bavail) + + devices = append(devices, mount) + } + } + + return devices, nil +} + +func unescapeString(str string) string { + return strings.ReplaceAll(str, "\\040", " ") +} diff --git a/pkg/device/dev_linux_test.go b/pkg/device/dev_linux_test.go new file mode 100644 index 0000000..114c1a3 --- /dev/null +++ b/pkg/device/dev_linux_test.go @@ -0,0 +1,71 @@ +//go:build linux + +package device + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetDevicesInfo(t *testing.T) { + getter := LinuxDevicesInfoGetter{MountsPath: "/proc/mounts"} + devices, _ := getter.GetDevicesInfo() + assert.IsType(t, Devices{}, devices) +} + +func TestGetDevicesInfoFail(t *testing.T) { + getter := LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"} + _, err := getter.GetDevicesInfo() + assert.Equal(t, "open /xxxyyy: no such file or directory", err.Error()) +} + +func TestSnapMountsNotShown(t *testing.T) { + mounts, _ := readMountsFile(strings.NewReader(`/dev/loop4 /var/lib/snapd/snap/core18/1944 squashfs ro,nodev,relatime 0 0 +/dev/loop3 /var/lib/snapd/snap/core20/904 squashfs ro,nodev,relatime 0 0 +/dev/nvme0n1p1 /boot vfat rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro 0 0`)) + + devices, err := processMounts(mounts, true) + assert.Len(t, devices, 1) + assert.Nil(t, err) +} + +func TestZfsMountsShown(t *testing.T) { + mounts, _ := readMountsFile(strings.NewReader(`rootpool/opt /opt zfs rw,nodev,relatime,xattr,posixacl 0 0 +rootpool/usr/local /usr/local zfs rw,nodev,relatime,xattr,posixacl 0 0 +rootpool/home/root /root zfs rw,nodev,relatime,xattr,posixacl 0 0 +rootpool/usr/games /usr/games zfs rw,nodev,relatime,xattr,posixacl 0 0 +rootpool/home /home zfs rw,nodev,relatime,xattr,posixacl 0 0 +/dev/loop4 /var/lib/snapd/snap/core18/1944 squashfs ro,nodev,relatime 0 0 +/dev/loop3 /var/lib/snapd/snap/core20/904 squashfs ro,nodev,relatime 0 0 +/dev/nvme0n1p1 /boot vfat rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro 0 0`)) + + devices, err := processMounts(mounts, true) + assert.Len(t, devices, 6) + assert.Nil(t, err) +} + +func TestNfsMountsShown(t *testing.T) { + // nolint: lll // Why: Test data + mounts, _ := readMountsFile(strings.NewReader(`host1:/dir1/ /mnt/dir1 nfs4 rw,nosuid,nodev,noatime,nodiratime,vers=4.2,rsize=1048576,wsize=1048576,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,clientaddr=192.168.1.1,fsc,local_lock=none,addr=192.168.1.2 0 0 +host2:/dir2/ /mnt/dir2 nfs rw,relatime,vers=3,rsize=524288,wsize=524288,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,mountaddr=192.168.1.3,mountvers=3,mountport=38081,mountproto=udp,fsc,local_lock=none,addr=192.168.1.4 0 0`)) + + devices, err := processMounts(mounts, true) + assert.Len(t, devices, 2) + assert.Equal(t, "host1:/dir1/", devices[0].Name) + assert.Equal(t, "/mnt/dir1", devices[0].MountPoint) + assert.Nil(t, err) +} + +func TestMountsWithSpaces(t *testing.T) { + // nolint: lll // Why: Test data + mounts, _ := readMountsFile(strings.NewReader(`host1:/dir1/ /mnt/dir\040with\040spaces nfs4 rw,nosuid,nodev,noatime,nodiratime,vers=4.2,rsize=1048576,wsize=1048576,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,clientaddr=192.168.1.1,fsc,local_lock=none,addr=192.168.1.2 0 0 +host2:/dir2/ /mnt/dir2 nfs rw,relatime,vers=3,rsize=524288,wsize=524288,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,mountaddr=192.168.1.3,mountvers=3,mountport=38081,mountproto=udp,fsc,local_lock=none,addr=192.168.1.4 0 0`)) + + devices, err := processMounts(mounts, true) + assert.Len(t, devices, 2) + assert.Equal(t, "host1:/dir1/", devices[0].Name) + assert.Equal(t, "/mnt/dir with spaces", devices[0].MountPoint) + assert.Nil(t, err) +} diff --git a/pkg/device/dev_netbsd.go b/pkg/device/dev_netbsd.go new file mode 100644 index 0000000..5c9be29 --- /dev/null +++ b/pkg/device/dev_netbsd.go @@ -0,0 +1,28 @@ +//go:build netbsd + +package device + +import ( + "strings" + + "golang.org/x/sys/unix" +) + +func processMounts(mounts Devices, ignoreErrors bool) (devices Devices, err error) { + for _, mount := range mounts { + if strings.HasPrefix(mount.Name, "/dev") || mount.Fstype == "zfs" { + info := &unix.Statvfs_t{} + err = unix.Statvfs(mount.MountPoint, info) + if err != nil && !ignoreErrors { + return nil, err + } + + mount.Size = int64(info.Bsize) * int64(info.Blocks) + mount.Free = int64(info.Bsize) * int64(info.Bavail) + + devices = append(devices, mount) + } + } + + return devices, nil +} diff --git a/pkg/device/dev_openbsd.go b/pkg/device/dev_openbsd.go new file mode 100644 index 0000000..1e378e7 --- /dev/null +++ b/pkg/device/dev_openbsd.go @@ -0,0 +1,29 @@ +//go:build openbsd + +package device + +import ( + "fmt" + "strings" + + "golang.org/x/sys/unix" +) + +func processMounts(mounts Devices, ignoreErrors bool) (devices Devices, err error) { + for _, mount := range mounts { + if strings.HasPrefix(mount.Name, "/dev") || mount.Fstype == "zfs" { + info := &unix.Statfs_t{} + err = unix.Statfs(mount.MountPoint, info) + if err != nil && !ignoreErrors { + return nil, fmt.Errorf("getting stats for mount point: \"%s\", %w", mount.MountPoint, err) + } + + mount.Size = int64(info.F_bsize) * int64(info.F_blocks) + mount.Free = int64(info.F_bsize) * int64(info.F_bavail) + + devices = append(devices, mount) + } + } + + return devices, nil +} diff --git a/pkg/device/dev_other.go b/pkg/device/dev_other.go new file mode 100644 index 0000000..4a9bd1a --- /dev/null +++ b/pkg/device/dev_other.go @@ -0,0 +1,21 @@ +//go:build windows || plan9 + +package device + +import "errors" + +// OtherDevicesInfoGetter returns info for other devices +type OtherDevicesInfoGetter struct{} + +// Getter is current instance of DevicesInfoGetter +var Getter DevicesInfoGetter = OtherDevicesInfoGetter{} + +// GetDevicesInfo returns result of GetMounts with usage info about mounted devices +func (t OtherDevicesInfoGetter) GetDevicesInfo() (devices Devices, err error) { + return nil, errors.New("Only Linux platform is supported for listing devices") +} + +// GetMounts returns all mounted filesystems +func (t OtherDevicesInfoGetter) GetMounts() (devices Devices, err error) { + return nil, errors.New("Only Linux platform is supported for listing mount points") +} diff --git a/pkg/device/dev_test.go b/pkg/device/dev_test.go new file mode 100644 index 0000000..2ce2673 --- /dev/null +++ b/pkg/device/dev_test.go @@ -0,0 +1,73 @@ +package device + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNested(t *testing.T) { + item := &Device{ + MountPoint: "/xxx", + } + nested := &Device{ + MountPoint: "/xxx/yyy", + } + notNested := &Device{ + MountPoint: "/zzz/yyy", + } + + mounts := Devices{item, nested, notNested} + + mountsNested := GetNestedMountpointsPaths("/xxx", mounts) + + assert.Len(t, mountsNested, 1) + assert.Equal(t, "/xxx/yyy", mountsNested[0]) +} + +func TestSortByName(t *testing.T) { + item := &Device{ + Name: "/xxx", + } + nested := &Device{ + Name: "/xxx/yyy", + } + notNested := &Device{ + Name: "/zzz/yyy", + } + + devices := Devices{item, nested, notNested} + + sort.Sort(sort.Reverse(ByName(devices))) + + assert.Equal(t, "/zzz/yyy", devices[0].Name) + assert.Equal(t, "/xxx/yyy", devices[1].Name) + assert.Equal(t, "/xxx", devices[2].Name) +} + +func TestSortByUsedSize(t *testing.T) { + item := &Device{ + Name: "xxx", + Size: 1e12, + Free: 1e3, + } + nested := &Device{ + Name: "yyy", + Size: 1e12, + Free: 1e6, + } + notNested := &Device{ + Name: "zzz", + Size: 1e12, + Free: 1e12, + } + + devices := Devices{item, nested, notNested} + + sort.Sort(ByUsedSize(devices)) + + assert.Equal(t, "zzz", devices[0].Name) + assert.Equal(t, "yyy", devices[1].Name) + assert.Equal(t, "xxx", devices[2].Name) +} diff --git a/pkg/fs/file.go b/pkg/fs/file.go new file mode 100644 index 0000000..7dd3f51 --- /dev/null +++ b/pkg/fs/file.go @@ -0,0 +1,177 @@ +package fs + +import ( + "io" + "iter" + "time" + + "github.com/maruel/natural" +) + +// SortBy represents the field to sort files by +type SortBy int + +const ( + SortBySize SortBy = iota + SortByName + SortByItemCount + SortByMtime + SortByApparentSize +) + +// SortOrder represents the sort direction +type SortOrder int + +const ( + SortAsc SortOrder = iota + SortDesc +) + +// Item is a FS item (file or dir) +type Item interface { + GetPath() string + GetName() string + GetFlag() rune + IsDir() bool + GetSize() int64 + GetType() string + GetUsage() int64 + GetMtime() time.Time + GetItemCount() int64 + GetParent() Item + SetParent(Item) + GetMultiLinkedInode() uint64 + EncodeJSON(writer io.Writer, topLevel bool) error + GetItemStats(linkedItems HardLinkedItems) (itemCount int64, size, usage int64) + UpdateStats(linkedItems HardLinkedItems) + AddFile(Item) + GetFiles(SortBy, SortOrder) iter.Seq[Item] + GetFilesLocked(SortBy, SortOrder) iter.Seq[Item] + RemoveFile(Item) + RemoveFileByName(name string) + RLock() func() +} + +// Files - slice of pointers to File +type Files []Item + +// HardLinkedItems maps inode number to array of all hard linked items +type HardLinkedItems map[uint64]Files + +// IndexOf searches File in Files and returns its index +func (f Files) IndexOf(file Item) (int, bool) { + for i, item := range f { + if item == file { + return i, true + } + } + return 0, false +} + +// FindByName searches name in Files and returns its index +func (f Files) FindByName(name string) (int, bool) { + for i, item := range f { + if item.GetName() == name { + return i, true + } + } + return 0, false +} + +// Remove removes File from Files +func (f Files) Remove(file Item) Files { + index, ok := f.IndexOf(file) + if !ok { + return f + } + return append(f[:index], f[index+1:]...) +} + +// RemoveByName removes File from Files +func (f Files) RemoveByName(name string) Files { + index, ok := f.FindByName(name) + if !ok { + return f + } + return append(f[:index], f[index+1:]...) +} + +func (f Files) Len() int { return len(f) } +func (f Files) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f Files) Less(i, j int) bool { + if f[i].GetUsage() != f[j].GetUsage() { + return f[i].GetUsage() < f[j].GetUsage() + } + // if usage is the same, sort by name + return natural.Less(f[i].GetName(), f[j].GetName()) +} + +// ByApparentSize sorts files by apparent size +type ByApparentSize Files + +func (f ByApparentSize) Len() int { return len(f) } +func (f ByApparentSize) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByApparentSize) Less(i, j int) bool { + if f[i].GetSize() != f[j].GetSize() { + return f[i].GetSize() < f[j].GetSize() + } + // if size is the same, sort by name + return natural.Less(f[i].GetName(), f[j].GetName()) +} + +// ByItemCount sorts files by item count +type ByItemCount Files + +func (f ByItemCount) Len() int { return len(f) } +func (f ByItemCount) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByItemCount) Less(i, j int) bool { + if f[i].GetItemCount() != f[j].GetItemCount() { + return f[i].GetItemCount() < f[j].GetItemCount() + } + // if item count is the same, sort by name + return natural.Less(f[i].GetName(), f[j].GetName()) +} + +// ByName sorts files by name +type ByName Files + +func (f ByName) Len() int { return len(f) } +func (f ByName) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByName) Less(i, j int) bool { return natural.Less(f[i].GetName(), f[j].GetName()) } + +// ByMtime sorts files by name +type ByMtime Files + +func (f ByMtime) Len() int { return len(f) } +func (f ByMtime) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f ByMtime) Less(i, j int) bool { + if !f[i].GetMtime().Equal(f[j].GetMtime()) { + return f[i].GetMtime().Before(f[j].GetMtime()) + } + // if item count is the same, sort by name + return natural.Less(f[i].GetName(), f[j].GetName()) +} + +// ParseSortBy converts a string to SortBy +func ParseSortBy(s string) SortBy { + switch s { + case "name": + return SortByName + case "size": + return SortBySize + case "itemCount": + return SortByItemCount + case "mtime": + return SortByMtime + default: + return SortBySize + } +} + +// ParseSortOrder converts a string to SortOrder +func ParseSortOrder(s string) SortOrder { + if s == "asc" { + return SortAsc + } + return SortDesc +} diff --git a/pkg/path/path.go b/pkg/path/path.go new file mode 100644 index 0000000..92714cc --- /dev/null +++ b/pkg/path/path.go @@ -0,0 +1,26 @@ +package path + +import "strings" + +// ShortenPath removes the last but one path components to fit into maxLen +func ShortenPath(path string, maxLen int) string { + if len(path) <= maxLen { + return path + } + + res := "" + parts := strings.SplitAfter(path, "/") + curLen := len(parts[len(parts)-1]) // count length of last part for start + + for _, part := range parts[:len(parts)-1] { + curLen += len(part) + if curLen > maxLen { + res += ".../" + break + } + res += part + } + + res += parts[len(parts)-1] + return res +} diff --git a/pkg/path/path_test.go b/pkg/path/path_test.go new file mode 100644 index 0000000..9598e28 --- /dev/null +++ b/pkg/path/path_test.go @@ -0,0 +1,15 @@ +package path + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShortenPath(t *testing.T) { + assert.Equal(t, "/root", ShortenPath("/root", 10)) + assert.Equal(t, "/home/.../foo", ShortenPath("/home/dundee/foo", 10)) + assert.Equal(t, "/home/dundee/foo", ShortenPath("/home/dundee/foo", 50)) + assert.Equal(t, "/home/dundee/.../bar.txt", ShortenPath("/home/dundee/foo/bar.txt", 20)) + assert.Equal(t, "/home/.../bar.txt", ShortenPath("/home/dundee/foo/bar.txt", 15)) +} diff --git a/pkg/remove/parallel.go b/pkg/remove/parallel.go new file mode 100644 index 0000000..022073c --- /dev/null +++ b/pkg/remove/parallel.go @@ -0,0 +1,62 @@ +package remove + +import ( + "os" + "runtime" + "sync" + + "github.com/dundee/gdu/v5/pkg/fs" +) + +var concurrencyLimit = make(chan struct{}, 3*runtime.GOMAXPROCS(0)) + +// ItemFromDirParallel removes item from dir +func ItemFromDirParallel(dir, item fs.Item) error { + if !item.IsDir() { + return ItemFromDir(dir, item) + } + errChan := make(chan error, 1) // we show only first error + var wait sync.WaitGroup + + // remove all files in the directory in parallel + for file := range item.GetFilesLocked(fs.SortBySize, fs.SortDesc) { + if !file.IsDir() { + continue + } + + wait.Add(1) + go func(itemPath string) { + concurrencyLimit <- struct{}{} + defer func() { <-concurrencyLimit }() + + err := os.RemoveAll(itemPath) + if err != nil { + select { + // write error to channel if it's empty + case errChan <- err: + default: + } + } + wait.Done() + }(file.GetPath()) + } + + wait.Wait() + + // check if there was an error + select { + case err := <-errChan: + return err + default: + } + + // remove the directory itself + err := os.RemoveAll(item.GetPath()) + if err != nil { + return err + } + + // update parent directory + dir.RemoveFile(item) + return nil +} diff --git a/pkg/remove/parallel_linux_test.go b/pkg/remove/parallel_linux_test.go new file mode 100644 index 0000000..69c550a --- /dev/null +++ b/pkg/remove/parallel_linux_test.go @@ -0,0 +1,66 @@ +//go:build linux + +package remove + +import ( + "os" + "testing" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestItemFromDirParallelWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Chmod("test_dir/nested", 0) + assert.Nil(t, err) + defer func() { + err = os.Chmod("test_dir/nested", 0o755) + assert.Nil(t, err) + }() + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + }, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "nested", + Parent: dir, + }, + } + + err = ItemFromDirParallel(dir, subdir) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestItemFromDirParallelWithErr2(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Chmod("test_dir/nested/subnested", 0) + assert.Nil(t, err) + defer func() { + err = os.Chmod("test_dir/nested/subnested", 0o755) + assert.Nil(t, err) + }() + + analyzer := analyze.CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*analyze.Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + subdir := dir.Files[0].(*analyze.Dir) + + err = ItemFromDirParallel(dir, subdir) + assert.Contains(t, err.Error(), "permission denied") +} diff --git a/pkg/remove/parallel_test.go b/pkg/remove/parallel_test.go new file mode 100644 index 0000000..f69b2d9 --- /dev/null +++ b/pkg/remove/parallel_test.go @@ -0,0 +1,69 @@ +package remove + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" +) + +func TestRemoveFileParallel(t *testing.T) { + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "xxx", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "yyy", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "zzz", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := ItemFromDirParallel(subdir, file) + assert.Nil(t, err) + + assert.Equal(t, 0, len(subdir.Files)) + assert.Equal(t, int64(1), subdir.ItemCount) + assert.Equal(t, int64(1), subdir.Size) + assert.Equal(t, int64(4), subdir.Usage) + assert.Equal(t, 1, len(dir.Files)) + assert.Equal(t, int64(2), dir.ItemCount) + assert.Equal(t, int64(2), dir.Size) +} + +func TestRemoveDirParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + analyzer := analyze.CreateAnalyzer() + dir := analyzer.AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, + ).(*analyze.Dir) + analyzer.GetDone().Wait() + dir.UpdateStats(make(fs.HardLinkedItems)) + + subdir := dir.Files[0].(*analyze.Dir) + + err := ItemFromDirParallel(dir, subdir) + assert.Nil(t, err) +} diff --git a/pkg/remove/remove.go b/pkg/remove/remove.go new file mode 100644 index 0000000..bf34df5 --- /dev/null +++ b/pkg/remove/remove.go @@ -0,0 +1,39 @@ +package remove + +import ( + "os" + + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" +) + +// ItemFromDir removes item from dir +func ItemFromDir(dir, item fs.Item) error { + err := os.RemoveAll(item.GetPath()) + if err != nil { + return err + } + + dir.RemoveFile(item) + return nil +} + +// EmptyFileFromDir empties file from dir (truncates to 0 bytes) +func EmptyFileFromDir(dir, file fs.Item) error { + err := os.Truncate(file.GetPath(), 0) + if err != nil { + return err + } + + // Remove old file and add zero-sized one + dir.RemoveFile(file) + newFile := &analyze.File{ + Name: file.GetName(), + Flag: file.GetFlag(), + Size: 0, + Usage: 0, + Parent: dir, + } + dir.AddFile(newFile) + return nil +} diff --git a/pkg/remove/remove_linux_test.go b/pkg/remove/remove_linux_test.go new file mode 100644 index 0000000..4d63b5a --- /dev/null +++ b/pkg/remove/remove_linux_test.go @@ -0,0 +1,41 @@ +//go:build linux + +package remove + +import ( + "os" + "testing" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/stretchr/testify/assert" +) + +func TestRemoveFileWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + err := os.Chmod("test_dir/nested", 0) + assert.Nil(t, err) + defer func() { + err = os.Chmod("test_dir/nested", 0o755) + assert.Nil(t, err) + }() + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + }, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "nested", + Parent: dir, + }, + } + + err = ItemFromDir(dir, subdir) + assert.Contains(t, err.Error(), "permission denied") +} diff --git a/pkg/remove/remove_test.go b/pkg/remove/remove_test.go new file mode 100644 index 0000000..d3fa015 --- /dev/null +++ b/pkg/remove/remove_test.go @@ -0,0 +1,130 @@ +package remove + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" +) + +func TestTruncateFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "nested", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "file2", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := EmptyFileFromDir(subdir, file) + + assert.Nil(t, err) + assert.Equal(t, 1, len(subdir.Files)) + assert.Equal(t, int64(1), subdir.ItemCount) // RemoveFile decrements, AddFile doesn't increment + assert.Equal(t, int64(1), subdir.Size) + assert.Equal(t, int64(4), subdir.Usage) + assert.Equal(t, 1, len(dir.Files)) + assert.Equal(t, int64(2), dir.ItemCount) // RemoveFile decrements, AddFile doesn't increment + assert.Equal(t, int64(2), dir.Size) +} + +func TestRemoveFile(t *testing.T) { + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "xxx", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "yyy", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "zzz", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := ItemFromDir(subdir, file) + assert.Nil(t, err) + + assert.Equal(t, 0, len(subdir.Files)) + assert.Equal(t, int64(1), subdir.ItemCount) + assert.Equal(t, int64(1), subdir.Size) + assert.Equal(t, int64(4), subdir.Usage) + assert.Equal(t, 1, len(dir.Files)) + assert.Equal(t, int64(2), dir.ItemCount) + assert.Equal(t, int64(2), dir.Size) +} + +func TestTruncateFileWithErr(t *testing.T) { + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "xxx", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "yyy", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "zzz", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := EmptyFileFromDir(subdir, file) + + assert.Contains(t, err.Error(), "no such file or directory") +} diff --git a/pkg/timefilter/timefilter.go b/pkg/timefilter/timefilter.go new file mode 100644 index 0000000..2d4280c --- /dev/null +++ b/pkg/timefilter/timefilter.go @@ -0,0 +1,250 @@ +package timefilter + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +// TimeBound represents a parsed time filter value that can be either an instant or a date-only value +type TimeBound struct { + instant *time.Time // absolute instant (UTC) + dateOnly *time.Time // at local midnight; only YYYY-MM-DD will set this +} + +// IsEmpty returns true if the TimeBound has no filter criteria +func (tb TimeBound) IsEmpty() bool { + return tb.instant == nil && tb.dateOnly == nil +} + +// TimeFilter represents multiple time filtering criteria +type TimeFilter struct { + since []*TimeBound + until []*TimeBound +} + +// NewTimeFilter creates a new TimeFilter with the given parameters +func NewTimeFilter(since, until, maxAge, minAge string, now time.Time, loc *time.Location) (*TimeFilter, error) { + tf := &TimeFilter{} + + // Parse since + if since != "" { + sinceBound, err := parseTimeValue(since, loc) + if err != nil { + return nil, fmt.Errorf("invalid --since value: %w", err) + } + if !sinceBound.IsEmpty() { + tf.since = append(tf.since, &sinceBound) + } + } + + // Parse until + if until != "" { + untilBound, err := parseTimeValue(until, loc) + if err != nil { + return nil, fmt.Errorf("invalid --until value: %w", err) + } + if !untilBound.IsEmpty() { + tf.until = append(tf.until, &untilBound) + } + } + + // Parse max-age (convert to since) + if maxAge != "" { + duration, err := parseDuration(maxAge) + if err != nil { + return nil, fmt.Errorf("invalid --max-age value: %w", err) + } + sinceTime := now.Add(-duration).UTC() + tf.since = append(tf.since, &TimeBound{instant: &sinceTime}) + } + + // Parse min-age (convert to until) + if minAge != "" { + duration, err := parseDuration(minAge) + if err != nil { + return nil, fmt.Errorf("invalid --min-age value: %w", err) + } + untilTime := now.Add(-duration).UTC() + tf.until = append(tf.until, &TimeBound{instant: &untilTime}) + } + + return tf, nil +} + +// IncludeByTimeFilter determines if a file should be included based on the complete time filter +func (tf *TimeFilter) IncludeByTimeFilter(mtime time.Time, loc *time.Location) bool { + // Check since bound + for _, since := range tf.since { + if !includeByTimeBound(mtime, *since, loc, false) { + return false + } + } + + // Check until bound + for _, until := range tf.until { + if !includeByTimeBound(mtime, *until, loc, true) { + return false + } + } + + return true +} + +// IsEmpty returns true if the TimeFilter has no filter criteria +func (tf *TimeFilter) IsEmpty() bool { + return tf.since == nil && tf.until == nil +} + +// FormatForDisplay returns a formatted string showing the active time filters +// This shows what the program actually parsed and is acting on +func (tf *TimeFilter) FormatForDisplay(loc *time.Location) string { + if tf.IsEmpty() { + return "" + } + + var parts []string + + for _, since := range tf.since { + if since.instant != nil { + parts = append(parts, "since="+since.instant.In(loc).Format(time.RFC3339)) + } else if since.dateOnly != nil { + parts = append(parts, "since="+since.dateOnly.Format("2006-01-02")+" (date-only)") + } + } + + for _, until := range tf.until { + if until.instant != nil { + parts = append(parts, "until=", until.instant.In(loc).Format(time.RFC3339)) + } else if until.dateOnly != nil { + parts = append(parts, "until=", until.dateOnly.Format("2006-01-02")+" (date-only)") + } + } + + if len(parts) == 0 { + return "" + } + + return " Filtered by: time=mtime; " + strings.Join(parts, "; ") +} + +// includeByTimeBound determines if a file should be included based on its mtime and the time bound +func includeByTimeBound(mtime time.Time, tb TimeBound, loc *time.Location, isUntil bool) bool { + if tb.instant == nil && tb.dateOnly == nil { + return true // no filter applied + } + + if tb.instant != nil { + if isUntil { + return !mtime.After(*tb.instant) // inclusive (<=) + } + return !mtime.Before(*tb.instant) // inclusive (>=) + } + + if tb.dateOnly != nil { + // For date-only comparisons, adjust the bound to cover the whole day. + boundDate := tb.dateOnly.In(loc) + + if isUntil { + // For `until`, we want to include the entire day. + // So the upper bound is the beginning of the *next* day. + upperBound := time.Date(boundDate.Year(), boundDate.Month(), boundDate.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1) + return mtime.Before(upperBound) + } + + // For `since`, we want to include the entire day. + // So the lower bound is the beginning of that day. + lowerBound := time.Date(boundDate.Year(), boundDate.Month(), boundDate.Day(), 0, 0, 0, 0, loc) + return !mtime.Before(lowerBound) // inclusive (>=) + } + + return true +} + +// parseDuration parses a duration string with support for extended units +// Supports: s, m, h, d (=24h), w (=7d), mo (=30d), y (=365d) +// Examples: "90m", "2h30m", "7d", "6w", "1y2mo" +func parseDuration(input string) (time.Duration, error) { + if input == "" { + return 0, fmt.Errorf("empty duration") + } + + // Remove whitespace and convert to lowercase + input = strings.ToLower(strings.ReplaceAll(input, " ", "")) + + // Regex to match number+unit pairs (mo must come before m to avoid greedy matching) + re := regexp.MustCompile(`(\d+)(mo|s|m|h|d|w|y)`) + matches := re.FindAllStringSubmatch(input, -1) + + if len(matches) == 0 { + return 0, fmt.Errorf("invalid duration format %q. Use combinations like 7d, 2h30m, 1y2mo", input) + } + + // Check if the entire input was consumed by matches + consumed := "" + for _, match := range matches { + consumed += match[0] + } + if consumed != input { + return 0, fmt.Errorf("invalid duration format %q. Use combinations like 7d, 2h30m, 1y2mo", input) + } + + var total time.Duration + for _, match := range matches { + value, err := strconv.Atoi(match[1]) + if err != nil { + return 0, fmt.Errorf("invalid number in duration: %s", match[1]) + } + + unit := match[2] + var duration time.Duration + + switch unit { + case "s": + duration = time.Duration(value) * time.Second + case "m": + duration = time.Duration(value) * time.Minute + case "h": + duration = time.Duration(value) * time.Hour + case "d": + duration = time.Duration(value) * 24 * time.Hour + case "w": + duration = time.Duration(value) * 7 * 24 * time.Hour + case "mo": + duration = time.Duration(value) * 30 * 24 * time.Hour + case "y": + duration = time.Duration(value) * 365 * 24 * time.Hour + default: + return 0, fmt.Errorf("unsupported duration unit: %s", unit) + } + + total += duration + } + + return total, nil +} + +// parseTimeValue parses a time value into either a timestamp instant or a date-only value +func parseTimeValue(arg string, loc *time.Location) (TimeBound, error) { + if arg == "" { + return TimeBound{}, nil + } + + // 1) Try RFC3339 instant + if t, err := time.Parse(time.RFC3339, arg); err == nil { + u := t.UTC() + return TimeBound{instant: &u}, nil + } + + // 2) Try strict YYYY-MM-DD + if len(arg) == 10 { + if d, err := time.ParseInLocation("2006-01-02", arg, loc); err == nil { + // dateOnly uses local date; we will compare date parts only + return TimeBound{dateOnly: &d}, nil + } + } + + return TimeBound{}, fmt.Errorf("invalid time value %q. Use RFC3339 timestamp or YYYY-MM-DD", arg) +} diff --git a/pkg/timefilter/timefilter_test.go b/pkg/timefilter/timefilter_test.go new file mode 100644 index 0000000..26ba756 --- /dev/null +++ b/pkg/timefilter/timefilter_test.go @@ -0,0 +1,767 @@ +package timefilter + +import ( + "testing" + "time" +) + +func TestParseSince(t *testing.T) { + // Use America/Vancouver timezone for testing (UTC-7 or UTC-8 depending on DST) + loc, err := time.LoadLocation("America/Vancouver") + if err != nil { + t.Fatalf("Failed to load timezone: %v", err) + } + + tests := []struct { + name string + input string + expectError bool + expectType string // "instant", "dateOnly", or "empty" + }{ + { + name: "empty string", + input: "", + expectError: false, + expectType: "empty", + }, + { + name: "RFC3339 with timezone", + input: "2025-08-11T01:00:00-07:00", + expectError: false, + expectType: "instant", + }, + { + name: "RFC3339 UTC", + input: "2025-08-11T08:00:00Z", + expectError: false, + expectType: "instant", + }, + { + name: "RFC3339 with nanoseconds", + input: "2025-08-11T01:00:00.123456789-07:00", + expectError: false, + expectType: "instant", + }, + { + name: "date only YYYY-MM-DD", + input: "2025-08-11", + expectError: false, + expectType: "dateOnly", + }, + { + name: "invalid format", + input: "2025/08/11", + expectError: true, + expectType: "", + }, + { + name: "invalid date", + input: "2025-13-01", + expectError: true, + expectType: "", + }, + { + name: "too short date", + input: "2025-8-1", + expectError: true, + expectType: "", + }, + { + name: "too long date", + input: "2025-08-011", + expectError: true, + expectType: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseTimeValue(tt.input, loc) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + switch tt.expectType { + case "empty": + if !result.IsEmpty() { + t.Errorf("Expected empty result") + } + case "instant": + if result.instant == nil { + t.Errorf("Expected instant to be set") + } + if result.dateOnly != nil { + t.Errorf("Expected dateOnly to be nil") + } + case "dateOnly": + if result.dateOnly == nil { + t.Errorf("Expected dateOnly to be set") + } + if result.instant != nil { + t.Errorf("Expected instant to be nil") + } + } + }) + } +} + +func TestIncludeBySince(t *testing.T) { + // Use America/Vancouver timezone for testing (UTC-7 or UTC-8 depending on DST) + loc, err := time.LoadLocation("America/Vancouver") + if err != nil { + t.Fatalf("Failed to load timezone: %v", err) + } + + // Test cases from the MVP document + tests := []struct { + name string + fileMtime string // local time + sinceArg string + expectInclude bool + }{ + { + name: "file before date boundary", + fileMtime: "2025-08-10T23:59:00-07:00", + sinceArg: "2025-08-11", + expectInclude: false, + }, + { + name: "file at start of date", + fileMtime: "2025-08-11T00:00:00-07:00", + sinceArg: "2025-08-11", + expectInclude: true, + }, + { + name: "file during date", + fileMtime: "2025-08-11T01:00:00-07:00", + sinceArg: "2025-08-11", + expectInclude: true, + }, + { + name: "file at end of date", + fileMtime: "2025-08-11T23:59:00-07:00", + sinceArg: "2025-08-11", + expectInclude: true, + }, + { + name: "file after date", + fileMtime: "2025-08-12T00:00:00-07:00", + sinceArg: "2025-08-11", + expectInclude: true, + }, + { + name: "instant mode - file before", + fileMtime: "2025-08-11T01:00:00-07:00", + sinceArg: "2025-08-11T02:00:00-07:00", + expectInclude: false, + }, + { + name: "instant mode - file after", + fileMtime: "2025-08-11T03:00:00-07:00", + sinceArg: "2025-08-11T02:00:00-07:00", + expectInclude: true, + }, + { + name: "instant mode - file exactly at boundary", + fileMtime: "2025-08-11T02:00:00-07:00", + sinceArg: "2025-08-11T02:00:00-07:00", + expectInclude: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse file mtime + fileMtime, err := time.Parse(time.RFC3339, tt.fileMtime) + if err != nil { + t.Fatalf("Failed to parse file mtime: %v", err) + } + + // Parse since bound + sinceBound, err := parseTimeValue(tt.sinceArg, loc) + if err != nil { + t.Fatalf("Failed to parse since arg: %v", err) + } + + // Test inclusion + result := includeByTimeBound(fileMtime, sinceBound, loc, false) + if result != tt.expectInclude { + t.Errorf("Expected include=%v, got include=%v", tt.expectInclude, result) + } + }) + } +} + +func TestIncludeBySinceEmpty(t *testing.T) { + loc, err := time.LoadLocation("America/Vancouver") + if err != nil { + t.Fatalf("Failed to load timezone: %v", err) + } + + // Test with empty since bound (no filter) + emptySince := TimeBound{} + testTime := time.Now() + + result := includeByTimeBound(testTime, emptySince, loc, false) + if !result { + t.Errorf("Expected true for empty since bound, got false") + } +} + +func TestTimeBoundIsEmpty(t *testing.T) { + tests := []struct { + name string + bound TimeBound + expected bool + }{ + { + name: "empty bound", + bound: TimeBound{}, + expected: true, + }, + { + name: "instant bound", + bound: TimeBound{instant: &time.Time{}}, + expected: false, + }, + { + name: "dateOnly bound", + bound: TimeBound{dateOnly: &time.Time{}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.bound.IsEmpty() + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestParseDuration(t *testing.T) { + tests := []struct { + name string + input string + expected time.Duration + expectError bool + }{ + { + name: "empty string", + input: "", + expectError: true, + }, + { + name: "seconds", + input: "30s", + expected: 30 * time.Second, + }, + { + name: "minutes", + input: "45m", + expected: 45 * time.Minute, + }, + { + name: "hours", + input: "2h", + expected: 2 * time.Hour, + }, + { + name: "days", + input: "7d", + expected: 7 * 24 * time.Hour, + }, + { + name: "weeks", + input: "2w", + expected: 2 * 7 * 24 * time.Hour, + }, + { + name: "months", + input: "3mo", + expected: 3 * 30 * 24 * time.Hour, + }, + { + name: "years", + input: "1y", + expected: 365 * 24 * time.Hour, + }, + { + name: "combined hours and minutes", + input: "2h30m", + expected: 2*time.Hour + 30*time.Minute, + }, + { + name: "combined with spaces", + input: "2 h 30 m", + expected: 2*time.Hour + 30*time.Minute, + }, + { + name: "complex combination", + input: "1y2mo3w4d5h6m7s", + expected: 365*24*time.Hour + 2*30*24*time.Hour + 3*7*24*time.Hour + 4*24*time.Hour + 5*time.Hour + 6*time.Minute + 7*time.Second, + }, + { + name: "uppercase", + input: "2H30M", + expected: 2*time.Hour + 30*time.Minute, + }, + { + name: "invalid format", + input: "2x", + expectError: true, + }, + { + name: "no number", + input: "h", + expectError: true, + }, + { + name: "partial match", + input: "2h30", + expectError: true, + }, + { + name: "invalid number", + input: "abch", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseDuration(tt.input) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestNewTimeFilter(t *testing.T) { + loc, err := time.LoadLocation("America/Vancouver") + if err != nil { + t.Fatalf("Failed to load timezone: %v", err) + } + + now := time.Date(2025, 8, 11, 12, 0, 0, 0, loc) + + tests := []struct { + name string + since string + until string + maxAge string + minAge string + expectError bool + expectEmpty bool + }{ + { + name: "empty filter", + expectEmpty: true, + }, + { + name: "since only", + since: "2025-08-10", + }, + { + name: "until only", + until: "2025-08-12", + }, + { + name: "max-age only", + maxAge: "7d", + }, + { + name: "min-age only", + minAge: "30d", + }, + { + name: "since and until", + since: "2025-08-01", + until: "2025-08-15", + }, + { + name: "max-age and min-age", + maxAge: "7d", + minAge: "1d", + }, + { + name: "all filters", + since: "2025-08-01", + until: "2025-08-15", + maxAge: "30d", + minAge: "1d", + }, + { + name: "invalid since", + since: "invalid", + expectError: true, + }, + { + name: "invalid until", + until: "invalid", + expectError: true, + }, + { + name: "invalid max-age", + maxAge: "invalid", + expectError: true, + }, + { + name: "invalid min-age", + minAge: "invalid", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter, err := NewTimeFilter(tt.since, tt.until, tt.maxAge, tt.minAge, now, loc) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if tt.expectEmpty { + if !filter.IsEmpty() { + t.Errorf("Expected empty filter") + } + } else { + if filter.IsEmpty() { + t.Errorf("Expected non-empty filter") + } + } + }) + } +} + +func TestTimeFilterIncludeByTimeFilter(t *testing.T) { + loc, err := time.LoadLocation("America/Vancouver") + if err != nil { + t.Fatalf("Failed to load timezone: %v", err) + } + + now := time.Date(2025, 8, 11, 12, 0, 0, 0, loc) + + tests := []struct { + name string + since string + until string + maxAge string + minAge string + fileMtime string + expectInclude bool + }{ + { + name: "since filter - file after", + since: "2025-08-10", + fileMtime: "2025-08-11T10:00:00-07:00", + expectInclude: true, + }, + { + name: "since filter - file before", + since: "2025-08-10", + fileMtime: "2025-08-09T10:00:00-07:00", + expectInclude: false, + }, + { + name: "until filter - file before", + until: "2025-08-12", + fileMtime: "2025-08-11T10:00:00-07:00", + expectInclude: true, + }, + { + name: "until filter - file after", + until: "2025-08-12", + fileMtime: "2025-08-13T10:00:00-07:00", + expectInclude: false, + }, + { + name: "max-age filter - file recent", + maxAge: "7d", + fileMtime: "2025-08-10T12:00:00-07:00", // 1 day ago + expectInclude: true, + }, + { + name: "max-age filter - file old", + maxAge: "7d", + fileMtime: "2025-08-01T12:00:00-07:00", // 10 days ago + expectInclude: false, + }, + { + name: "min-age filter - file old", + minAge: "7d", + fileMtime: "2025-08-01T12:00:00-07:00", // 10 days ago + expectInclude: true, + }, + { + name: "min-age filter - file recent", + minAge: "7d", + fileMtime: "2025-08-10T12:00:00-07:00", // 1 day ago + expectInclude: false, + }, + { + name: "combined filters - all pass", + since: "2025-08-01", + until: "2025-08-15", + maxAge: "30d", + minAge: "1d", + fileMtime: "2025-08-05T12:00:00-07:00", // 6 days ago + expectInclude: true, + }, + { + name: "combined filters - since fails", + since: "2025-08-10", + until: "2025-08-15", + maxAge: "30d", + minAge: "1d", + fileMtime: "2025-08-05T12:00:00-07:00", // 6 days ago + expectInclude: false, + }, + { + name: "combined filters - until fails", + since: "2025-08-01", + until: "2025-08-10", + maxAge: "30d", + minAge: "1d", + fileMtime: "2025-08-12T12:00:00-07:00", // future + expectInclude: false, + }, + { + name: "combined filters - max-age fails", + since: "2025-08-01", + until: "2025-08-15", + maxAge: "5d", + minAge: "1d", + fileMtime: "2025-08-01T12:00:00-07:00", // 10 days ago + expectInclude: false, + }, + { + name: "combined filters - min-age fails", + since: "2025-08-01", + until: "2025-08-15", + maxAge: "30d", + minAge: "5d", + fileMtime: "2025-08-10T12:00:00-07:00", // 1 day ago + expectInclude: false, + }, + { + name: "date-only since and max-age - fail", + since: "2025-08-10", + maxAge: "3d", + fileMtime: "2025-08-09T12:00:00-07:00", // 2 days old, but before since date + expectInclude: false, + }, + { + name: "date-only since and max-age - pass", + since: "2025-08-10", + maxAge: "3d", + fileMtime: "2025-08-10T12:00:00-07:00", // 1 day old, and on since date + expectInclude: true, + }, + { + name: "date-only until and min-age - fail", + until: "2025-08-10", + minAge: "1d", + fileMtime: "2025-08-10T12:00:00-07:00", // 1 day old, but not old enough to be excluded by until + expectInclude: true, + }, + { + name: "date-only until and min-age - pass", + until: "2025-08-10", + minAge: "2d", + fileMtime: "2025-08-08T12:00:00-07:00", // 3 days old, and before until date + expectInclude: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse file mtime + fileMtime, err := time.Parse(time.RFC3339, tt.fileMtime) + if err != nil { + t.Fatalf("Failed to parse file mtime: %v", err) + } + + // Create time filter + filter, err := NewTimeFilter(tt.since, tt.until, tt.maxAge, tt.minAge, now, loc) + if err != nil { + t.Fatalf("Failed to create time filter: %v", err) + } + + // Test inclusion + result := filter.IncludeByTimeFilter(fileMtime, loc) + if result != tt.expectInclude { + t.Errorf("Expected include=%v, got include=%v", tt.expectInclude, result) + } + }) + } +} + +func TestIncludeByTimeBound(t *testing.T) { + loc, err := time.LoadLocation("America/Vancouver") + if err != nil { + t.Fatalf("Failed to load timezone: %v", err) + } + + tests := []struct { + name string + boundArg string + fileMtime string + isUntil bool + expectInclude bool + }{ + { + name: "since instant - file after", + boundArg: "2025-08-11T10:00:00-07:00", + fileMtime: "2025-08-11T11:00:00-07:00", + isUntil: false, + expectInclude: true, + }, + { + name: "since instant - file before", + boundArg: "2025-08-11T10:00:00-07:00", + fileMtime: "2025-08-11T09:00:00-07:00", + isUntil: false, + expectInclude: false, + }, + { + name: "since instant - file exactly at boundary", + boundArg: "2025-08-11T10:00:00-07:00", + fileMtime: "2025-08-11T10:00:00-07:00", + isUntil: false, + expectInclude: true, + }, + { + name: "until instant - file before", + boundArg: "2025-08-11T10:00:00-07:00", + fileMtime: "2025-08-11T09:00:00-07:00", + isUntil: true, + expectInclude: true, + }, + { + name: "until instant - file after", + boundArg: "2025-08-11T10:00:00-07:00", + fileMtime: "2025-08-11T11:00:00-07:00", + isUntil: true, + expectInclude: false, + }, + { + name: "until instant - file exactly at boundary", + boundArg: "2025-08-11T10:00:00-07:00", + fileMtime: "2025-08-11T10:00:00-07:00", + isUntil: true, + expectInclude: true, + }, + { + name: "since date - file just before day", + boundArg: "2025-08-11", + fileMtime: "2025-08-10T23:59:59-07:00", + isUntil: false, + expectInclude: false, + }, + { + name: "since date - file at start of day", + boundArg: "2025-08-11", + fileMtime: "2025-08-11T00:00:00-07:00", + isUntil: false, + expectInclude: true, + }, + { + name: "since date - file at end of day", + boundArg: "2025-08-11", + fileMtime: "2025-08-11T23:59:59-07:00", + isUntil: false, + expectInclude: true, + }, + { + name: "since date - file on next day", + boundArg: "2025-08-11", + fileMtime: "2025-08-12T00:00:00-07:00", + isUntil: false, + expectInclude: true, + }, + { + name: "until date - file on previous day", + boundArg: "2025-08-11", + fileMtime: "2025-08-10T23:59:59-07:00", + isUntil: true, + expectInclude: true, + }, + { + name: "until date - file at start of day", + boundArg: "2025-08-11", + fileMtime: "2025-08-11T00:00:00-07:00", + isUntil: true, + expectInclude: true, + }, + { + name: "until date - file at end of day", + boundArg: "2025-08-11", + fileMtime: "2025-08-11T23:59:59-07:00", + isUntil: true, + expectInclude: true, + }, + { + name: "until date - file just after day", + boundArg: "2025-08-11", + fileMtime: "2025-08-12T00:00:00-07:00", + isUntil: true, + expectInclude: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse time bound + bound, err := parseTimeValue(tt.boundArg, loc) + if err != nil { + t.Fatalf("Failed to parse time bound: %v", err) + } + + // Parse file mtime + fileMtime, err := time.Parse(time.RFC3339, tt.fileMtime) + if err != nil { + t.Fatalf("Failed to parse file mtime: %v", err) + } + + // Test inclusion + result := includeByTimeBound(fileMtime, bound, loc, tt.isUntil) + if result != tt.expectInclude { + t.Errorf("Expected include=%v, got include=%v", tt.expectInclude, result) + } + }) + } +} diff --git a/report/export.go b/report/export.go new file mode 100644 index 0000000..f95cfca --- /dev/null +++ b/report/export.go @@ -0,0 +1,265 @@ +package report + +import ( + "bytes" + "errors" + "fmt" + "io" + "math" + "os" + "strconv" + "sync" + "time" + + "github.com/dundee/gdu/v5/build" + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/fatih/color" +) + +// UI struct +type UI struct { + *common.UI + output io.Writer + exportOutput io.Writer + red *color.Color + orange *color.Color + writtenChan chan struct{} +} + +// CreateExportUI creates UI for stdout +func CreateExportUI( + output io.Writer, + exportOutput io.Writer, + useColors bool, + showProgress bool, + useSIPrefix bool, +) *UI { + ui := &UI{ + UI: &common.UI{ + ShowProgress: showProgress, + Analyzer: analyze.CreateAnalyzer(), + UseSIPrefix: useSIPrefix, + }, + output: output, + exportOutput: exportOutput, + writtenChan: make(chan struct{}), + } + ui.red = color.New(color.FgRed).Add(color.Bold) + ui.orange = color.New(color.FgYellow).Add(color.Bold) + + if !useColors { + color.NoColor = true + } + + return ui +} + +// StartUILoop stub +func (ui *UI) StartUILoop() error { + return nil +} + +// SetCollapsePath sets the flag to collapse paths +func (ui *UI) SetCollapsePath(value bool) { +} + +// ListDevices lists mounted devices and shows their disk usage +func (ui *UI) ListDevices(getter device.DevicesInfoGetter) error { + return errors.New("exporting devices list is not supported") +} + +// ReadAnalysis reads analysis report from JSON file +func (ui *UI) ReadAnalysis(input io.Reader) error { + return errors.New("reading analysis is not possible while exporting") +} + +// ReadFromStorage reads analysis data from persistent key-value storage +func (ui *UI) ReadFromStorage(storagePath, path string) error { + storage := analyze.NewStorage(storagePath, path) + closeFn := storage.Open() + defer closeFn() + + dir, err := storage.GetDirForPath(path) + if err != nil { + return err + } + + var waitWritten sync.WaitGroup + if ui.ShowProgress { + waitWritten.Add(1) + go func() { + defer waitWritten.Done() + ui.updateProgress() + }() + } + + return ui.exportDir(dir, &waitWritten) +} + +// AnalyzePath analyzes recursively disk usage in given path +func (ui *UI) AnalyzePath(path string, _ fs.Item) error { + var ( + dir fs.Item + wait sync.WaitGroup + waitWritten sync.WaitGroup + ) + + if ui.ShowProgress { + waitWritten.Add(1) + go func() { + defer waitWritten.Done() + ui.updateProgress() + }() + } + + wait.Add(1) + go func() { + defer wait.Done() + dir = ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.CreateFileTypeFilter()) + dir.UpdateStats(make(fs.HardLinkedItems, 10)) + }() + + wait.Wait() + + return ui.exportDir(dir, &waitWritten) +} + +func (ui *UI) exportDir(dir fs.Item, waitWritten *sync.WaitGroup) error { + // Sorting is now handled by GetFiles with sort parameters + + var ( + buff bytes.Buffer + err error + ) + + buff.Write([]byte(`[1,2,{"progname":"gdu","progver":"`)) + buff.Write([]byte(build.Version)) + buff.Write([]byte(`","timestamp":`)) + buff.Write([]byte(strconv.FormatInt(time.Now().Unix(), 10))) + buff.Write([]byte("},\n")) + + if err := dir.EncodeJSON(&buff, true); err != nil { + return err + } + if _, err = buff.Write([]byte("]\n")); err != nil { + return err + } + if _, err = buff.WriteTo(ui.exportOutput); err != nil { + return err + } + + if f, ok := ui.exportOutput.(*os.File); ok { + err = f.Close() + if err != nil { + return err + } + } + + if ui.ShowProgress { + ui.writtenChan <- struct{}{} + waitWritten.Wait() + } + + return nil +} + +func (ui *UI) updateProgress() { + waitingForWrite := false + + emptyRow := "\r" + for j := 0; j < 100; j++ { + emptyRow += " " + } + + progressRunes := []rune(`⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧`) + + progressChan := ui.Analyzer.GetProgressChan() + doneChan := ui.Analyzer.GetDone() + + var progress common.CurrentProgress + + i := 0 + for { + fmt.Fprint(ui.output, emptyRow) + + select { + case progress = <-progressChan: + case <-doneChan: + fmt.Fprint(ui.output, "\r") + waitingForWrite = true + case <-ui.writtenChan: + fmt.Fprint(ui.output, "\r") + return + default: + } + + fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) + + if waitingForWrite { + fmt.Fprint(ui.output, "Writing output file...") + } else { + fmt.Fprint(ui.output, "Scanning... Total items: "+ + ui.red.Sprint(common.FormatNumber(int64(progress.ItemCount)))+ + " size: "+ + ui.formatSize(progress.TotalSize)) + } + + time.Sleep(100 * time.Millisecond) + i++ + i %= 10 + } +} + +func (ui *UI) formatSize(size int64) string { + if ui.UseSIPrefix { + return ui.formatWithDecPrefix(size) + } + return ui.formatWithBinPrefix(size) +} + +func (ui *UI) formatWithBinPrefix(size int64) string { + fsize := float64(size) + asize := math.Abs(fsize) + + switch { + case asize >= common.Ei: + return ui.orange.Sprintf("%.1f", fsize/common.Ei) + " EiB" + case asize >= common.Pi: + return ui.orange.Sprintf("%.1f", fsize/common.Pi) + " PiB" + case asize >= common.Ti: + return ui.orange.Sprintf("%.1f", fsize/common.Ti) + " TiB" + case asize >= common.Gi: + return ui.orange.Sprintf("%.1f", fsize/common.Gi) + " GiB" + case asize >= common.Mi: + return ui.orange.Sprintf("%.1f", fsize/common.Mi) + " MiB" + case asize >= common.Ki: + return ui.orange.Sprintf("%.1f", fsize/common.Ki) + " KiB" + default: + return ui.orange.Sprintf("%d", size) + " B" + } +} + +func (ui *UI) formatWithDecPrefix(size int64) string { + fsize := float64(size) + asize := math.Abs(fsize) + + switch { + case asize >= common.E: + return ui.orange.Sprintf("%.1f", fsize/common.E) + " EB" + case asize >= common.P: + return ui.orange.Sprintf("%.1f", fsize/common.P) + " PB" + case asize >= common.T: + return ui.orange.Sprintf("%.1f", fsize/common.T) + " TB" + case asize >= common.G: + return ui.orange.Sprintf("%.1f", fsize/common.G) + " GB" + case asize >= common.M: + return ui.orange.Sprintf("%.1f", fsize/common.M) + " MB" + case asize >= common.K: + return ui.orange.Sprintf("%.1f", fsize/common.K) + " kB" + default: + return ui.orange.Sprintf("%d", size) + " B" + } +} diff --git a/report/export_linux_test.go b/report/export_linux_test.go new file mode 100644 index 0000000..f25bef3 --- /dev/null +++ b/report/export_linux_test.go @@ -0,0 +1,55 @@ +//go:build linux + +package report + +import ( + "bytes" + "os" + "testing" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/stretchr/testify/assert" +) + +func TestReadFromStorage(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + const storagePath = "/tmp/badger-test2" + defer func() { + err := os.RemoveAll(storagePath) + if err != nil { + panic(err) + } + }() + + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + ui.SetAnalyzer(analyze.CreateStoredAnalyzer(storagePath)) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.ReadFromStorage(storagePath, "test_dir") + + assert.Nil(t, err) + assert.Contains(t, reportOutput.String(), `"name":"nested"`) +} + +func TestReadFromStorageWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + const storagePath = "/tmp/badger-test3" + + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, false, false) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.ReadFromStorage(storagePath, "test_dir") + + assert.ErrorContains(t, err, "Key not found") +} diff --git a/report/export_test.go b/report/export_test.go new file mode 100644 index 0000000..c79b2f8 --- /dev/null +++ b/report/export_test.go @@ -0,0 +1,133 @@ +package report + +import ( + "bytes" + "os" + "testing" + + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestAnalyzePath(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, false, false) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, reportOutput.String(), `"name":"nested"`) +} + +func TestAnalyzePathWithProgress(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, true, true, true) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, reportOutput.String(), `"name":"nested"`) +} + +func TestShowDevices(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false) + err := ui.ListDevices(device.Getter) + + assert.Contains(t, err.Error(), "not supported") +} + +func TestReadAnalysisWhileExporting(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false) + err := ui.ReadAnalysis(output) + + assert.Contains(t, err.Error(), "not possible while exporting") +} + +func TestExportToFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + reportOutput, err := os.OpenFile("output.json", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + assert.Nil(t, err) + defer func() { + os.Remove("output.json") + }() + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err = ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + assert.Nil(t, err) + + reportOutput, err = os.OpenFile("output.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + _, err = reportOutput.Seek(0, 0) + assert.Nil(t, err) + buff := make([]byte, 200) + _, err = reportOutput.Read(buff) + assert.Nil(t, err) + + assert.Contains(t, string(buff), `"name":"nested"`) +} + +func TestFormatSize(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, false) + + assert.Contains(t, ui.formatSize(1), "B") + assert.Contains(t, ui.formatSize(1<<10+1), "KiB") + assert.Contains(t, ui.formatSize(1<<20+1), "MiB") + assert.Contains(t, ui.formatSize(1<<30+1), "GiB") + assert.Contains(t, ui.formatSize(1<<40+1), "TiB") + assert.Contains(t, ui.formatSize(1<<50+1), "PiB") + assert.Contains(t, ui.formatSize(1<<60+1), "EiB") + assert.Contains(t, ui.formatSize(-1<<10-1), "KiB") +} + +func TestFormatSizeDec(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + reportOutput := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateExportUI(output, reportOutput, false, true, true) + + assert.Contains(t, ui.formatSize(1), "B") + assert.Contains(t, ui.formatSize(1<<10+1), "kB") + assert.Contains(t, ui.formatSize(1<<20+1), "MB") + assert.Contains(t, ui.formatSize(1<<30+1), "GB") + assert.Contains(t, ui.formatSize(1<<40+1), "TB") + assert.Contains(t, ui.formatSize(1<<50+1), "PB") + assert.Contains(t, ui.formatSize(1<<60+1), "EB") + assert.Contains(t, ui.formatSize(-1<<10-1), "kB") +} diff --git a/report/import.go b/report/import.go new file mode 100644 index 0000000..5f795fa --- /dev/null +++ b/report/import.go @@ -0,0 +1,109 @@ +package report + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "time" + + "github.com/dundee/gdu/v5/pkg/analyze" +) + +// ReadAnalysis reads analysis report from JSON file and returns directory item +func ReadAnalysis(input io.Reader) (dir *analyze.Dir, err error) { + var data interface{} + + var buff bytes.Buffer + if _, err = buff.ReadFrom(input); err != nil { + return nil, err + } + if err := json.Unmarshal(buff.Bytes(), &data); err != nil { + return nil, err + } + + dataArray, ok := data.([]interface{}) + if !ok { + return nil, errors.New("JSON file does not contain top level array") + } + if len(dataArray) < 4 { + return nil, errors.New("top level array must have at least 4 items") + } + + items, ok := dataArray[3].([]interface{}) + if !ok { + return nil, errors.New("array of maps not found in the top level array on 4th position") + } + + return processDir(items) +} + +func processDir(items []interface{}) (dir *analyze.Dir, err error) { + dir = &analyze.Dir{ + File: &analyze.File{ + Flag: ' ', + }, + } + dirMap, ok := items[0].(map[string]interface{}) + if !ok { + return nil, errors.New("directory item is not a map") + } + name, ok := dirMap["name"].(string) + if !ok { + return nil, errors.New("directory name is not a string") + } + if mtime, ok := dirMap["mtime"].(float64); ok { + dir.Mtime = time.Unix(int64(mtime), 0) + } + + slashPos := strings.LastIndex(name, "/") + if slashPos > -1 { + dir.Name = name[slashPos+1:] + dir.BasePath = name[:slashPos+1] + } else { + dir.Name = name + } + + for _, v := range items[1:] { + switch item := v.(type) { + case map[string]interface{}: + file := &analyze.File{} + file.Name = item["name"].(string) + + if asize, ok := item["asize"].(float64); ok { + file.Size = int64(asize) + } + if dsize, ok := item["dsize"].(float64); ok { + file.Usage = int64(dsize) + } + if mtime, ok := item["mtime"].(float64); ok { + file.Mtime = time.Unix(int64(mtime), 0) + } + if _, ok := item["notreg"].(bool); ok { + file.Flag = '@' + } else { + file.Flag = ' ' + } + if mli, ok := item["ino"].(float64); ok { + file.Mli = uint64(mli) + } + if _, ok := item["hlnkc"].(bool); ok { + file.Flag = 'H' + } + + file.Parent = dir + + dir.AddFile(file) + case []interface{}: + subdir, err := processDir(item) + if err != nil { + return nil, err + } + subdir.Parent = dir + dir.AddFile(subdir) + } + } + + return dir, nil +} diff --git a/report/import_test.go b/report/import_test.go new file mode 100644 index 0000000..cc69dbf --- /dev/null +++ b/report/import_test.go @@ -0,0 +1,111 @@ +package report + +import ( + "bytes" + "errors" + "testing" + + "github.com/dundee/gdu/v5/pkg/analyze" + log "github.com/sirupsen/logrus" + + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestReadAnalysis(t *testing.T) { + buff := bytes.NewBuffer([]byte(` + [1,2,{"progname":"gdu","progver":"development","timestamp":1626806293}, + [{"name":"/home/xxx","mtime":1629333600}, + {"name":"gdu.json","asize":33805233,"dsize":33808384}, + {"name":"sock","notreg":true}, + [{"name":"app"}, + {"name":"app.go","asize":4638,"dsize":8192}, + {"name":"app_linux_test.go","asize":1410,"dsize":4096}, + {"name":"app_linux_test2.go","ino":1234,"hlnkc":true,"asize":1410,"dsize":4096}, + {"name":"app_test.go","asize":4974,"dsize":8192}], + {"name":"main.go","asize":3205,"dsize":4096,"mtime":1629333600}]] + `)) + + dir, err := ReadAnalysis(buff) + + assert.Nil(t, err) + assert.Equal(t, "xxx", dir.GetName()) + assert.Equal(t, "/home/xxx", dir.GetPath()) + assert.Equal(t, 2021, dir.GetMtime().Year()) + assert.Equal(t, 2021, dir.Files[3].GetMtime().Year()) + alt2 := dir.Files[2].(*analyze.Dir).Files[2].(*analyze.File) + assert.Equal(t, "app_linux_test2.go", alt2.Name) + assert.Equal(t, uint64(1234), alt2.Mli) + assert.Equal(t, 'H', alt2.Flag) +} + +func TestReadAnalysisWithEmptyInput(t *testing.T) { + buff := bytes.NewBuffer([]byte(``)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "unexpected end of JSON input", err.Error()) +} + +func TestReadAnalysisWithEmptyDict(t *testing.T) { + buff := bytes.NewBuffer([]byte(`{}`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "JSON file does not contain top level array", err.Error()) +} + +func TestReadFromBrokenInput(t *testing.T) { + _, err := ReadAnalysis(&BrokenInput{}) + + assert.Equal(t, "IO error", err.Error()) +} + +func TestReadAnalysisWithEmptyArray(t *testing.T) { + buff := bytes.NewBuffer([]byte(`[]`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "top level array must have at least 4 items", err.Error()) +} + +func TestReadAnalysisWithWrongContent(t *testing.T) { + buff := bytes.NewBuffer([]byte(`[1,2,3,4]`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "array of maps not found in the top level array on 4th position", err.Error()) +} + +func TestReadAnalysisWithEmptyDirContent(t *testing.T) { + buff := bytes.NewBuffer([]byte(`[1,2,3,[{}]]`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "directory name is not a string", err.Error()) +} + +func TestReadAnalysisWithWrongDirItem(t *testing.T) { + buff := bytes.NewBuffer([]byte(`[1,2,3,[1, 2, 3]]`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "directory item is not a map", err.Error()) +} + +func TestReadAnalysisWithWrongSubdirItem(t *testing.T) { + buff := bytes.NewBuffer([]byte(`[1,2,3,[{"name":"xxx"}, [1,2,3]]]`)) + + _, err := ReadAnalysis(buff) + + assert.Equal(t, "directory item is not a map", err.Error()) +} + +type BrokenInput struct{} + +func (i *BrokenInput) Read(p []byte) (n int, err error) { + return 0, errors.New("IO error") +} diff --git a/snapcraft.yaml b/snapcraft.yaml new file mode 100644 index 0000000..43fa0ba --- /dev/null +++ b/snapcraft.yaml @@ -0,0 +1,30 @@ +name: gdu-disk-usage-analyzer +version: git +summary: Pretty fast disk usage analyzer written in Go. +description: | + Gdu is intended primarily for SSD disks where it can fully utilize parallel processing. + However HDDs work as well, but the performance gain is not so huge. +confinement: strict +base: core20 +parts: + gdu: + plugin: go + source: . + override-build: | + GO111MODULE=on CGO_ENABLED=0 go build \ + -buildmode=pie -trimpath -mod=readonly -modcacherw \ + -ldflags \ + "-s -w \ + -X 'github.com/dundee/gdu/v5/build.Version=$(git describe)' \ + -X 'github.com/dundee/gdu/v5/build.User=$(id -u -n)' \ + -X 'github.com/dundee/gdu/v5/build.Time=$(LC_ALL=en_US.UTF-8 date)' \ + -X 'github.com/dundee/gdu/v5/build.RootPathPrefix=/var/lib/snapd/hostfs'" \ + -o $SNAPCRAFT_PART_INSTALL/gdu \ + github.com/dundee/gdu/v5/cmd/gdu + $SNAPCRAFT_PART_INSTALL/gdu -v +apps: + gdu: + command: gdu + plugs: + - mount-observe + - system-backup diff --git a/stdout/stdout.go b/stdout/stdout.go new file mode 100644 index 0000000..ea1eeb4 --- /dev/null +++ b/stdout/stdout.go @@ -0,0 +1,618 @@ +package stdout + +import ( + "fmt" + "io" + "math" + "runtime" + "sync" + "time" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/dundee/gdu/v5/report" + "github.com/fatih/color" +) + +// UI struct +type UI struct { + output io.Writer + *common.UI + red *color.Color + orange *color.Color + blue *color.Color + showItemCnt bool + top int + depth int + summarize bool + noPrefix bool + fixedBase float64 + fixedSuffix string + reverseSort bool +} + +var ( + progressRunes = []rune(`⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧`) + progressRunesOld = []rune(`-\\|/`) + progressRunesCount = len(progressRunes) +) + +// CreateStdoutUI creates UI for stdout +func CreateStdoutUI( + output io.Writer, + useColors bool, + showProgress bool, + showApparentSize bool, + showRelativeSize bool, + summarize bool, + useSIPrefix bool, + noPrefix bool, + fixedUnit string, + top int, + reverseSort bool, + depth int, +) *UI { + ui := &UI{ + UI: &common.UI{ + UseColors: useColors, + ShowProgress: showProgress, + ShowApparentSize: showApparentSize, + ShowRelativeSize: showRelativeSize, + Analyzer: analyze.CreateAnalyzer(), + UseSIPrefix: useSIPrefix, + }, + output: output, + summarize: summarize, + noPrefix: noPrefix, + top: top, + reverseSort: reverseSort, + depth: depth, + } + if fixedUnit != "" { + ui.SetFixedUnit(fixedUnit) + } + ui.red = color.New(color.FgRed).Add(color.Bold) + ui.orange = color.New(color.FgYellow).Add(color.Bold) + ui.blue = color.New(color.FgBlue).Add(color.Bold) + + if !useColors { + color.NoColor = true + } + + return ui +} +func (ui *UI) SetFixedUnit(unitChar string) { + k, m, g := common.Ki, common.Mi, common.Gi + suffixMap := map[string]string{"k": " KiB", "m": " MiB", "g": " GiB"} + + if ui.UseSIPrefix { + k, m, g = common.K, common.M, common.G + suffixMap = map[string]string{"k": " kB", "m": " MB", "g": " GB"} + } + + switch unitChar { + case "k": + ui.fixedBase = k + ui.fixedSuffix = suffixMap["k"] + case "m": + ui.fixedBase = m + ui.fixedSuffix = suffixMap["m"] + case "g": + ui.fixedBase = g + ui.fixedSuffix = suffixMap["g"] + } +} + +func (ui *UI) SetShowItemCount() { + ui.showItemCnt = true +} + +func (ui *UI) UseOldProgressRunes() { + progressRunes = progressRunesOld + progressRunesCount = len(progressRunes) +} + +// StartUILoop stub +func (ui *UI) StartUILoop() error { + return nil +} + +// SetCollapsePath sets the flag to collapse paths +func (ui *UI) SetCollapsePath(value bool) { +} + +// ListDevices lists mounted devices and shows their disk usage +func (ui *UI) ListDevices(getter device.DevicesInfoGetter) error { + devices, err := getter.GetDevicesInfo() + if err != nil { + return err + } + + maxDeviceNameLength := maxInt(maxLength( + devices, + func(device *device.Device) string { return device.Name }, + ), len("Devices")) + + var sizeLength, percentLength int + if ui.UseColors { + sizeLength = 20 + percentLength = 16 + } else { + sizeLength = 9 + percentLength = 5 + } + + lineFormat := fmt.Sprintf( + "%%%ds %%%ds %%%ds %%%ds %%%ds %%s\n", + maxDeviceNameLength, + sizeLength, + sizeLength, + sizeLength, + percentLength, + ) + + fmt.Fprintf( + ui.output, + fmt.Sprintf("%%%ds %%9s %%9s %%9s %%5s %%s\n", maxDeviceNameLength), + "Device", + "Size", + "Used", + "Free", + "Used%", + "Mount point", + ) + + for _, device := range devices { + usedPercent := math.Round(float64(device.Size-device.Free) / float64(device.Size) * 100) + + fmt.Fprintf( + ui.output, + lineFormat, + device.Name, + ui.formatSize(device.Size), + ui.formatSize(device.Size-device.Free), + ui.formatSize(device.Free), + ui.red.Sprintf("%.f%%", usedPercent), + device.MountPoint) + } + + return nil +} + +// AnalyzePath analyzes recursively disk usage in given path +func (ui *UI) AnalyzePath(path string, _ fs.Item) error { + var ( + dir fs.Item + wait sync.WaitGroup + updateStatsDone chan struct{} + ) + updateStatsDone = make(chan struct{}, 1) + + if ui.ShowProgress { + wait.Add(1) + go func() { + defer wait.Done() + ui.updateProgress(updateStatsDone) + }() + } + + wait.Add(1) + go func() { + defer wait.Done() + dir = ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.CreateFileTypeFilter()) + dir.UpdateStats(make(fs.HardLinkedItems, 10)) + updateStatsDone <- struct{}{} + }() + + wait.Wait() + + switch { + case ui.top > 0: + ui.printTopFiles(dir) + case ui.depth > 0: + ui.printDirWithDepth(dir, 0) + case ui.summarize: + ui.printTotalItem(dir) + default: + ui.showDir(dir) + } + + return nil +} + +// ReadFromStorage reads analysis data from persistent key-value storage +func (ui *UI) ReadFromStorage(storagePath, path string) error { + storage := analyze.NewStorage(storagePath, path) + closeFn := storage.Open() + defer closeFn() + + dir, err := storage.GetDirForPath(path) + if err != nil { + return err + } + + switch { + case ui.top > 0: + ui.printTopFiles(dir) + case ui.summarize: + ui.printTotalItem(dir) + default: + ui.showDir(dir) + } + return nil +} + +func (ui *UI) showDir(dir fs.Item) { + sortOrder := fs.SortDesc + if ui.reverseSort { + sortOrder = fs.SortAsc + } + + for file := range dir.GetFiles(fs.SortBySize, sortOrder) { + ui.printItem(file) + } +} + +func (ui *UI) printTopFiles(file fs.Item) { + collected := analyze.CollectTopFiles(file, ui.top) + for _, file := range collected { + ui.printItemPath(file) + } +} + +func (ui *UI) printTotalItem(file fs.Item) { + var lineFormat string + if ui.UseColors { + lineFormat = "%20s %s\n" + } else { + lineFormat = "%9s %s\n" + } + + var size int64 + if ui.ShowApparentSize { + size = file.GetSize() + } else { + size = file.GetUsage() + } + + fmt.Fprintf( + ui.output, + lineFormat, + ui.formatSize(size), + file.GetName(), + ) +} + +func (ui *UI) printItem(file fs.Item) { + var lineFormat string + if ui.showItemCnt { + if ui.UseColors { + lineFormat = "%s %23s %25s %s\n" + } else { + lineFormat = "%s %9s %11s %s\n" + } + } else { + if ui.UseColors { + lineFormat = "%s %23s %s\n" + } else { + lineFormat = "%s %9s %s\n" + } + } + + var size int64 + if ui.ShowApparentSize { + size = file.GetSize() + } else { + size = file.GetUsage() + } + + countToDisplay := file.GetItemCount() + if file.IsDir() { + countToDisplay-- + } + + name := file.GetName() + if file.IsDir() { + name = ui.blue.Sprint("/" + file.GetName()) + } + + if ui.showItemCnt { + fmt.Fprintf( + ui.output, + lineFormat, + string(file.GetFlag()), + ui.formatSize(size), + ui.formatCount(countToDisplay), + name, + ) + return + } + + fmt.Fprintf( + ui.output, + lineFormat, + string(file.GetFlag()), + ui.formatSize(size), + name, + ) +} + +func (ui *UI) printItemPath(file fs.Item) { + var lineFormat string + if ui.UseColors { + lineFormat = "%20s %s\n" + } else { + lineFormat = "%9s %s\n" + } + + var size int64 + if ui.ShowApparentSize { + size = file.GetSize() + } else { + size = file.GetUsage() + } + + if file.IsDir() { + fmt.Fprintf(ui.output, + lineFormat, + ui.formatSize(size), + ui.blue.Sprint(file.GetPath())) + } else { + fmt.Fprintf(ui.output, + lineFormat, + ui.formatSize(size), + file.GetPath()) + } +} + +func (ui *UI) printDirWithDepth(dir fs.Item, currentDepth int) { + // Print current directory + ui.printItemPath(dir) + + // If we haven't reached the max depth, print contents + if currentDepth < ui.depth && dir.IsDir() { + sortOrder := fs.SortDesc + if ui.reverseSort { + sortOrder = fs.SortAsc + } + + files := dir.GetFiles(fs.SortBySize, sortOrder) + + // Print all files at this depth level + for file := range files { + if file.IsDir() { + // Recurse into subdirectories + ui.printDirWithDepth(file, currentDepth+1) + } else { + // Print regular files + ui.printItemPath(file) + } + } + } +} + +// ReadAnalysis reads analysis report from JSON file +func (ui *UI) ReadAnalysis(input io.Reader) error { + var ( + dir fs.Item + wait sync.WaitGroup + err error + doneChan chan struct{} + ) + + if ui.ShowProgress { + wait.Add(1) + doneChan = make(chan struct{}) + go func() { + defer wait.Done() + ui.showReadingProgress(doneChan) + }() + } + + wait.Add(1) + go func() { + defer wait.Done() + dir, err = report.ReadAnalysis(input) + if err != nil { + if ui.ShowProgress { + doneChan <- struct{}{} + } + return + } + runtime.GC() + + dir.UpdateStats(make(fs.HardLinkedItems, 10)) + + if ui.ShowProgress { + doneChan <- struct{}{} + } + }() + + wait.Wait() + + if err != nil { + return err + } + + if ui.summarize { + ui.printTotalItem(dir) + } else { + ui.showDir(dir) + } + + return nil +} + +func (ui *UI) showReadingProgress(doneChan chan struct{}) { + emptyRow := "\r" + for j := 0; j < 40; j++ { + emptyRow += " " + } + + i := 0 + for { + fmt.Fprint(ui.output, emptyRow) + + select { + case <-doneChan: + fmt.Fprint(ui.output, "\r") + return + default: + } + + fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) + fmt.Fprint(ui.output, "Reading analysis from file...") + + time.Sleep(100 * time.Millisecond) + i++ + i %= progressRunesCount + } +} + +func (ui *UI) updateProgress(updateStatsDone <-chan struct{}) { + emptyRow := "\r" + for j := 0; j < 100; j++ { + emptyRow += " " + } + + progressChan := ui.Analyzer.GetProgressChan() + analysisDoneChan := ui.Analyzer.GetDone() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + var progress common.CurrentProgress + + i := 0 + for { + select { + case <-ticker.C: + select { + case progress = <-progressChan: + fmt.Fprint(ui.output, emptyRow) + fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) + fmt.Fprint(ui.output, "Scanning... Total items: "+ + ui.red.Sprint(common.FormatNumber(int64(progress.ItemCount)))+ + " size: "+ + ui.formatSize(progress.TotalSize)) + default: + // Update only the spinner without clearing the line + fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) + } + i++ + i %= progressRunesCount + case <-analysisDoneChan: + ticker.Stop() + fmt.Fprint(ui.output, emptyRow) + for { + fmt.Fprint(ui.output, emptyRow) + fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) + fmt.Fprint(ui.output, "Calculating disk usage...") + time.Sleep(100 * time.Millisecond) + i++ + i %= progressRunesCount + + select { + case <-updateStatsDone: + fmt.Fprint(ui.output, emptyRow) + fmt.Fprint(ui.output, "\r") + return + default: + } + } + } + } +} + +func (ui *UI) formatCount(count int64) string { + count64 := float64(count) + + switch { + case count64 >= common.G: + return ui.red.Sprintf("%.1f", float64(count)/float64(common.G)) + "G" + case count64 >= common.M: + return ui.red.Sprintf("%.1f", float64(count)/float64(common.M)) + "M" + case count64 >= common.K: + return ui.red.Sprintf("%.1f", float64(count)/float64(common.K)) + "k" + default: + return ui.red.Sprintf("%d", count) + } +} + +func (ui *UI) formatSize(size int64) string { + if ui.noPrefix { + return ui.orange.Sprintf("%d", size) + } + if ui.fixedBase > 0 { + val := float64(size) / ui.fixedBase + return ui.orange.Sprintf("%.1f", val) + ui.fixedSuffix + } + if ui.UseSIPrefix { + return ui.formatWithDecPrefix(size) + } + return ui.formatWithBinPrefix(size) +} + +func (ui *UI) formatWithBinPrefix(size int64) string { + fsize := float64(size) + asize := math.Abs(fsize) + + switch { + case asize >= common.Ei: + return ui.orange.Sprintf("%.1f", fsize/common.Ei) + " EiB" + case asize >= common.Pi: + return ui.orange.Sprintf("%.1f", fsize/common.Pi) + " PiB" + case asize >= common.Ti: + return ui.orange.Sprintf("%.1f", fsize/common.Ti) + " TiB" + case asize >= common.Gi: + return ui.orange.Sprintf("%.1f", fsize/common.Gi) + " GiB" + case asize >= common.Mi: + return ui.orange.Sprintf("%.1f", fsize/common.Mi) + " MiB" + case asize >= common.Ki: + return ui.orange.Sprintf("%.1f", fsize/common.Ki) + " KiB" + default: + return ui.orange.Sprintf("%d", size) + " B" + } +} + +func (ui *UI) formatWithDecPrefix(size int64) string { + fsize := float64(size) + asize := math.Abs(fsize) + + switch { + case asize >= common.E: + return ui.orange.Sprintf("%.1f", fsize/common.E) + " EB" + case asize >= common.P: + return ui.orange.Sprintf("%.1f", fsize/common.P) + " PB" + case asize >= common.T: + return ui.orange.Sprintf("%.1f", fsize/common.T) + " TB" + case asize >= common.G: + return ui.orange.Sprintf("%.1f", fsize/common.G) + " GB" + case asize >= common.M: + return ui.orange.Sprintf("%.1f", fsize/common.M) + " MB" + case asize >= common.K: + return ui.orange.Sprintf("%.1f", fsize/common.K) + " kB" + default: + return ui.orange.Sprintf("%d", size) + " B" + } +} + +func maxLength(list []*device.Device, keyGetter func(*device.Device) string) int { + maxLen := 0 + var s string + for _, item := range list { + s = keyGetter(item) + if len(s) > maxLen { + maxLen = len(s) + } + } + return maxLen +} + +func maxInt(x, y int) int { + if x > y { + return x + } + return y +} diff --git a/stdout/stdout_linux_test.go b/stdout/stdout_linux_test.go new file mode 100644 index 0000000..fcd89d3 --- /dev/null +++ b/stdout/stdout_linux_test.go @@ -0,0 +1,27 @@ +//go:build linux + +package stdout + +import ( + "bytes" + "testing" + + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/pkg/device" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestShowDevicesWithErr(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + getter := device.LinuxDevicesInfoGetter{MountsPath: "/xyzxyz"} + ui := CreateStdoutUI(output, false, true, false, false, false, false, false, "", 0, false, 0) + err := ui.ListDevices(getter) + + assert.Contains(t, err.Error(), "no such file") +} diff --git a/stdout/stdout_test.go b/stdout/stdout_test.go new file mode 100644 index 0000000..d484d73 --- /dev/null +++ b/stdout/stdout_test.go @@ -0,0 +1,573 @@ +package stdout + +import ( + "bytes" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/internal/testanalyze" + "github.com/dundee/gdu/v5/internal/testdev" + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestAnalyzePath(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "nested") +} + +func TestShowItemCountInNonInteractiveMode(t *testing.T) { + tmpDir := t.TempDir() + + for dirName, fileCount := range map[string]int{"a": 5, "b": 10, "c": 15} { + dirPath := filepath.Join(tmpDir, dirName) + err := os.Mkdir(dirPath, 0o755) + assert.Nil(t, err) + + for i := 0; i < fileCount; i++ { + filePath := filepath.Join(dirPath, "f"+string(rune('a'+i))) + err = os.WriteFile(filePath, []byte("x"), 0o644) + assert.Nil(t, err) + } + } + + output := bytes.NewBuffer(make([]byte, 10)) + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) + ui.SetShowItemCount() + + err := ui.AnalyzePath(tmpDir, nil) + assert.Nil(t, err) + + out := output.String() + assert.Regexp(t, regexp.MustCompile(`(?m)\s+5\s+/a$`), out) + assert.Regexp(t, regexp.MustCompile(`(?m)\s+10\s+/b$`), out) + assert.Regexp(t, regexp.MustCompile(`(?m)\s+15\s+/c$`), out) +} + +func TestShowItemCountInNonInteractiveModeWithColorsAndFile(t *testing.T) { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "single") + err := os.WriteFile(filePath, []byte("x"), 0o644) + assert.Nil(t, err) + + output := bytes.NewBuffer(make([]byte, 10)) + ui := CreateStdoutUI(output, true, false, false, false, false, false, false, "", 0, false, 0) + ui.SetShowItemCount() + + err = ui.AnalyzePath(tmpDir, nil) + assert.Nil(t, err) + + out := output.String() + assert.Regexp(t, regexp.MustCompile(`(?m)\s+1\s+single$`), out) +} + +func TestShowSummary(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, true, false, true, false, true, false, false, "", 0, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir") +} + +func TestShowSummaryBw(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, true, false, false, "", 0, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir") +} + +func TestShowTop(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, true, false, true, false, true, false, false, "", 2, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir/nested/subnested/file") + assert.Contains(t, output.String(), "test_dir/nested/file2") +} + +func TestShowTopBw(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, true, false, false, "", 2, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir/nested/subnested/file") + assert.Contains(t, output.String(), "test_dir/nested/file2") +} + +func TestShowDepth(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 2) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir") + assert.Contains(t, output.String(), "test_dir/nested") + assert.Contains(t, output.String(), "test_dir/nested/subnested") +} + +func TestShowDepthWithColors(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, true, false, false, false, false, false, false, "", 0, false, 2) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir") + assert.Contains(t, output.String(), "test_dir/nested") +} + +func TestShowDepthWithReverseSort(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, true, 2) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + outputStr := output.String() + assert.Contains(t, outputStr, "test_dir") + assert.Contains(t, outputStr, "test_dir/nested") + assert.Contains(t, outputStr, "test_dir/nested/subnested") +} + +func TestAnalyzeSubdir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir/nested", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "file2") +} + +func TestAnalyzePathWithColors(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, true, false, true, false, false, false, false, "", 0, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir/nested", nil) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "subnested") +} + +func TestAnalyzePathWoUnicode(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, true, true, false, false, false, false, "", 0, false, 0) + ui.UseOldProgressRunes() + err := ui.AnalyzePath("test_dir/nested", nil) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "subnested") +} + +func TestItemRows(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, true, false, false, false, false, false, "", 0, false, 0) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + err := ui.AnalyzePath("test_dir", nil) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "KiB") +} + +func TestAnalyzePathWithProgress(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, true, true, false, false, false, false, "", 0, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "nested") +} + +func TestShowDevices(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, true, false, false, false, false, false, "", 0, false, 0) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "Device") + assert.Contains(t, output.String(), "xxx") +} + +func TestShowDevicesWithColor(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, "", 0, false, 0) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "Device") + assert.Contains(t, output.String(), "xxx") +} + +func TestReadAnalysisWithColor(t *testing.T) { + input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, "", 0, false, 0) + err = ui.ReadAnalysis(input) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "main.go") +} + +func TestReadAnalysisBw(t *testing.T) { + input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) + err = ui.ReadAnalysis(input) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "main.go") +} + +func TestReadAnalysisWithWrongFile(t *testing.T) { + input, err := os.OpenFile("../internal/testdata/wrong.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, "", 0, false, 0) + err = ui.ReadAnalysis(input) + + assert.NotNil(t, err) +} + +func TestReadAnalysisWithSummarize(t *testing.T) { + input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, false, false, false, true, false, false, "", 0, false, 0) + err = ui.ReadAnalysis(input) + + assert.Nil(t, err) + assert.Contains(t, output.String(), " gdu\n") +} + +func TestMaxInt(t *testing.T) { + assert.Equal(t, 5, maxInt(2, 5)) + assert.Equal(t, 4, maxInt(4, 2)) +} + +func TestFormatSize(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, "", 0, false, 0) + + assert.Contains(t, ui.formatSize(1), "B") + assert.Contains(t, ui.formatSize(1<<10+1), "KiB") + assert.Contains(t, ui.formatSize(1<<20+1), "MiB") + assert.Contains(t, ui.formatSize(1<<30+1), "GiB") + assert.Contains(t, ui.formatSize(1<<40+1), "TiB") + assert.Contains(t, ui.formatSize(1<<50+1), "PiB") + assert.Contains(t, ui.formatSize(1<<60+1), "EiB") +} + +func TestFormatSizeDec(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, true, false, "", 0, false, 0) + + assert.Contains(t, ui.formatSize(1), "B") + assert.Contains(t, ui.formatSize(1<<10+1), "kB") + assert.Contains(t, ui.formatSize(1<<20+1), "MB") + assert.Contains(t, ui.formatSize(1<<30+1), "GB") + assert.Contains(t, ui.formatSize(1<<40+1), "TB") + assert.Contains(t, ui.formatSize(1<<50+1), "PB") + assert.Contains(t, ui.formatSize(1<<60+1), "EB") +} + +func TestFormatCount(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + ui := CreateStdoutUI(output, true, true, true, false, false, true, false, "", 0, false, 0) + + assert.Equal(t, "42", ui.formatCount(42)) + assert.Equal(t, "1.5k", ui.formatCount(1500)) + assert.Equal(t, "2.5M", ui.formatCount(2500000)) + assert.Equal(t, "3.5G", ui.formatCount(3500000000)) +} + +func TestFormatSizeRaw(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, true, true, "", 0, false, 0) + + assert.Equal(t, ui.formatSize(1), "1") + assert.Equal(t, ui.formatSize(1<<10+1), "1025") + assert.Equal(t, ui.formatSize(1<<20+1), "1048577") + assert.Equal(t, ui.formatSize(1<<30+1), "1073741825") + assert.Equal(t, ui.formatSize(1<<40+1), "1099511627777") + assert.Equal(t, ui.formatSize(1<<50+1), "1125899906842625") + assert.Equal(t, ui.formatSize(1<<60+1), "1152921504606846977") +} +func TestFormatSizeFixedUnitBinary(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "k", 0, false, 0) + assert.Equal(t, "0.1 KiB", ui.formatSize(100)) + assert.Equal(t, "1500.0 KiB", ui.formatSize(1536000)) + + ui = CreateStdoutUI(output, false, false, false, false, false, false, false, "m", 0, false, 0) + assert.Equal(t, "0.1 MiB", ui.formatSize(100*1024)) + assert.Equal(t, "1500.0 MiB", ui.formatSize(1536000*1024)) + + ui = CreateStdoutUI(output, false, false, false, false, false, false, false, "g", 0, false, 0) + assert.Equal(t, "0.1 GiB", ui.formatSize(100*1024*1024)) + assert.Equal(t, "1500.0 GiB", ui.formatSize(1536000*1024*1024)) +} +func TestFormatSizeFixedUnitSI(t *testing.T) { + output := bytes.NewBuffer(make([]byte, 10)) + + // -k --si + ui := CreateStdoutUI(output, false, false, false, false, false, true, false, "k", 0, false, 0) + assert.Equal(t, "0.1 kB", ui.formatSize(100)) + assert.Equal(t, "1500.0 kB", ui.formatSize(15e+5)) + + ui = CreateStdoutUI(output, false, false, false, false, false, true, false, "m", 0, false, 0) + assert.Equal(t, "0.1 MB", ui.formatSize(1e+5)) + assert.Equal(t, "1500.0 MB", ui.formatSize(1.5e+9)) + + ui = CreateStdoutUI(output, false, false, false, false, false, true, false, "g", 0, false, 0) + assert.Equal(t, "0.1 GB", ui.formatSize(1e+8)) + assert.Equal(t, "1500.0 GB", ui.formatSize(1.5e+12)) +} + +// func printBuffer(buff *bytes.Buffer) { +// for i, x := range buff.String() { +// println(i, string(x)) +// } +// } + +func getDevicesInfoMock() device.DevicesInfoGetter { + item := &device.Device{ + Name: "xxx", + } + + mock := testdev.DevicesInfoGetterMock{} + mock.Devices = []*device.Device{item} + return mock +} + +// New tests for reverse sort functionality +func TestAnalyzePathWithReverseSort(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, true, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "nested") + + // Verify that smaller items appear first when reverse sort is enabled + outputStr := output.String() + lines := strings.Split(outputStr, "\n") + + // Filter out empty lines and progress lines + var fileLines []string + for _, line := range lines { + if strings.Contains(line, " ") && !strings.Contains(line, "Scanning") { + fileLines = append(fileLines, line) + } + } + + // With reverse sort, smaller files should appear before larger ones + assert.True(t, len(fileLines) > 0, "Should have file entries in output") +} + +func TestAnalyzePathWithoutReverseSort(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "nested") +} + +func TestReverseSortWithColors(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, true, false, true, false, false, false, false, "", 0, true, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir/nested", nil) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "subnested") +} + +func TestReverseSortWithSummarize(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, true, false, false, "", 0, true, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir") +} + +func TestReverseSortWithTop(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, true, false, true, false, true, false, false, "", 2, true, 0) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir/nested/subnested/file") + assert.Contains(t, output.String(), "test_dir/nested/file2") +} + +func TestReverseSortFromAnalysisFile(t *testing.T) { + input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + output := bytes.NewBuffer(make([]byte, 10)) + + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, "", 0, true, 0) + err = ui.ReadAnalysis(input) + + assert.Nil(t, err) + assert.Contains(t, output.String(), "main.go") +} diff --git a/tui/actions.go b/tui/actions.go new file mode 100644 index 0000000..02e9bff --- /dev/null +++ b/tui/actions.go @@ -0,0 +1,418 @@ +package tui + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "runtime/debug" + "strconv" + "strings" + "time" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/dundee/gdu/v5/build" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/dundee/gdu/v5/report" +) + +const ( + defaultLinesCount = 500 + linesThreshold = 20 + + actionEmpty = "empty" + actionDelete = "delete" + + actingEmpty = "emptying" + actingDelete = "deleting" +) + +// ListDevices lists mounted devices and shows their disk usage +func (ui *UI) ListDevices(getter device.DevicesInfoGetter) error { + var err error + ui.getter = getter + ui.devices, err = getter.GetDevicesInfo() + if err != nil { + return err + } + + ui.showDevices() + + return nil +} + +// AnalyzePath analyzes recursively disk usage for given path +func (ui *UI) AnalyzePath(path string, parentDir fs.Item) error { + ui.progress = tview.NewTextView().SetText("Scanning...") + ui.progress.SetBorder(true).SetBorderPadding(2, 2, 2, 2) + ui.progress.SetTitle(" Scanning... ") + ui.progress.SetDynamicColors(true) + + flex := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(ui.progress, 8, 1, false). + AddItem(nil, 0, 1, false), 0, 50, false). + AddItem(nil, 0, 1, false) + + ui.pages.AddPage("progress", flex, true, true) + + go ui.updateProgress() + + go func() { + defer debug.FreeOSMemory() + currentDir := ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.CreateFileTypeFilter()) + + if parentDir != nil { + currentDir.SetParent(parentDir) + // Remove old entry with the same name and add new one + parentDir.RemoveFileByName(currentDir.GetName()) + parentDir.AddFile(currentDir) + } else { + ui.topDirPath = path + ui.topDir = currentDir + } + + ui.topDir.UpdateStats(ui.linkedItems) + + ui.app.QueueUpdateDraw(func() { + ui.currentDir = currentDir + ui.showDir() + ui.pages.RemovePage("progress") + }) + + if ui.done != nil { + ui.done <- struct{}{} + } + }() + + return nil +} + +// ReadAnalysis reads analysis report from JSON file +func (ui *UI) ReadAnalysis(input io.Reader) error { + ui.progress = tview.NewTextView().SetText("Reading analysis from file...") + ui.progress.SetBorder(true).SetBorderPadding(2, 2, 2, 2) + ui.progress.SetTitle(" Reading... ") + ui.progress.SetDynamicColors(true) + + flex := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 10, 1, false). + AddItem(ui.progress, 8, 1, false). + AddItem(nil, 10, 1, false), 0, 50, false). + AddItem(nil, 0, 1, false) + + ui.pages.AddPage("progress", flex, true, true) + + go func() { + var err error + ui.currentDir, err = report.ReadAnalysis(input) + if err != nil { + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage("progress") + ui.showErr("Error reading file", err) + }) + if ui.done != nil { + ui.done <- struct{}{} + } + return + } + runtime.GC() + + ui.topDirPath = ui.currentDir.GetPath() + ui.topDir = ui.currentDir + + links := make(fs.HardLinkedItems, 10) + ui.topDir.UpdateStats(links) + + ui.app.QueueUpdateDraw(func() { + ui.showDir() + ui.pages.RemovePage("progress") + }) + + if ui.done != nil { + ui.done <- struct{}{} + } + }() + + return nil +} + +// ReadFromStorage reads analysis data from persistent key-value storage +func (ui *UI) ReadFromStorage(storagePath, path string) error { + storage := analyze.NewStorage(storagePath, path) + closeFn := storage.Open() + defer closeFn() + + dir, err := storage.GetDirForPath(path) + if err != nil { + return err + } + + ui.currentDir = dir + ui.topDirPath = ui.currentDir.GetPath() + ui.topDir = ui.currentDir + + ui.showDir() + return nil +} + +func (ui *UI) delete(shouldEmpty bool) { + if len(ui.markedRows) > 0 { + ui.deleteMarked(shouldEmpty) + } else { + ui.deleteSelected(shouldEmpty) + } +} + +func (ui *UI) deleteSelected(shouldEmpty bool) { + row, column := ui.table.GetSelection() + selectedItem := ui.table.GetCell(row, column).GetReference().(fs.Item) + + if ui.deleteInBackground { + ui.queueForDeletion([]fs.Item{selectedItem}, shouldEmpty) + return + } + + var action, acting string + if shouldEmpty { + action = actionEmpty + acting = actingEmpty + } else { + action = actionDelete + acting = actingDelete + } + modal := tview.NewModal().SetText( + cases.Title(language.English).String(acting) + + " " + + tview.Escape(selectedItem.GetName()) + + "...", + ) + ui.pages.AddPage(acting, modal, true, true) + + var currentDir fs.Item + var deleteItems []fs.Item + if shouldEmpty && selectedItem.IsDir() { + currentDir = selectedItem + for file := range currentDir.GetFiles(fs.SortBySize, fs.SortDesc) { + deleteItems = append(deleteItems, file) + } + } else { + currentDir = ui.currentDir + deleteItems = append(deleteItems, selectedItem) + } + + var deleteFun func(fs.Item, fs.Item) error + if shouldEmpty && !selectedItem.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + go func() { + for _, item := range deleteItems { + if err := deleteFun(currentDir, item); err != nil { + msg := "Can't " + action + " " + tview.Escape(selectedItem.GetName()) + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.showErr(msg, err) + }) + if ui.done != nil { + ui.done <- struct{}{} + } + return + } + } + + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + x, y := ui.table.GetOffset() + ui.showDir() + ui.table.Select(min(row, ui.table.GetRowCount()-1), 0) + ui.table.SetOffset(min(x, ui.table.GetRowCount()-1), y) + }) + + if ui.done != nil { + ui.done <- struct{}{} + } + }() +} + +func (ui *UI) showInfo() { + if ui.currentDir == nil { + return + } + + var content, numberColor string + row, column := ui.table.GetSelection() + selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) + + if ui.UseColors { + numberColor = fmt.Sprintf( + "[%s::b]", + ui.resultRow.NumberColor, + ) + } else { + numberColor = defaultColorBold + } + + linesCount := 12 + + text := tview.NewTextView().SetDynamicColors(true) + text.SetBorder(true).SetBorderPadding(2, 2, 2, 2) + text.SetBorderColor(tcell.ColorDefault) + text.SetTitle(" Item info ") + + content += "[::b]Name:[::-] " + content += tview.Escape(selectedFile.GetName()) + "\n" + content += "[::b]Path:[::-] " + content += tview.Escape( + strings.TrimPrefix(selectedFile.GetPath(), build.RootPathPrefix), + ) + "\n" + content += "[::b]Type:[::-] " + selectedFile.GetType() + "\n\n" + + content += " [::b]Disk usage:[::-] " + content += numberColor + ui.formatSize(selectedFile.GetUsage(), false, true) + content += fmt.Sprintf(" (%s%d[-::] B)", numberColor, selectedFile.GetUsage()) + "\n" + content += "[::b]Apparent size:[::-] " + content += numberColor + ui.formatSize(selectedFile.GetSize(), false, true) + content += fmt.Sprintf(" (%s%d[-::] B)", numberColor, selectedFile.GetSize()) + "\n" + + if selectedFile.GetMultiLinkedInode() > 0 { + linkedItems := ui.linkedItems[selectedFile.GetMultiLinkedInode()] + linesCount += 2 + len(linkedItems) + content += "\nHard-linked files:\n" + for _, linkedItem := range linkedItems { + content += "\t" + linkedItem.GetPath() + "\n" + } + } + + text.SetText(content) + + flex := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(text, linesCount, 1, false). + AddItem(nil, 0, 1, false), 80, 1, false). + AddItem(nil, 0, 1, false) + + ui.pages.AddPage("info", flex, true, true) +} + +func (ui *UI) openItem() { + row, column := ui.table.GetSelection() + selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) + if !ok || selectedFile == ui.currentDir.GetParent() { + return + } + + openBinary := "xdg-open" + + switch runtime.GOOS { + case "darwin": + openBinary = "open" + case "windows": + openBinary = "explorer" + } + + cmd := exec.Command(openBinary, selectedFile.GetPath()) + err := cmd.Start() + if err != nil { + ui.showErr("Error opening", err) + } +} + +func (ui *UI) confirmExport() *tview.Form { + form := tview.NewForm(). + AddInputField("File name", "export.json", 30, nil, func(v string) { + ui.exportName = v + }). + AddButton("Export", ui.exportAnalysis). + SetButtonsAlign(tview.AlignCenter) + form.SetBorder(true). + SetTitle(" Export data to JSON "). + SetInputCapture(func(key *tcell.EventKey) *tcell.EventKey { + if key.Key() == tcell.KeyEsc { + ui.pages.RemovePage("export") + ui.app.SetFocus(ui.table) + return nil + } + return key + }) + flex := modal(form, 50, 7) + ui.pages.AddPage("export", flex, true, true) + ui.app.SetFocus(form) + return form +} + +func (ui *UI) exportAnalysis() { + ui.pages.RemovePage("export") + + text := tview.NewTextView().SetText("Export in progress...").SetTextAlign(tview.AlignCenter) + text.SetBorder(true).SetTitle(" Export data to JSON ") + flex := modal(text, 50, 3) + ui.pages.AddPage("exporting", flex, true, true) + + go func() { + var err error + defer ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage("exporting") + if err == nil { + ui.app.SetFocus(ui.table) + } + }) + if ui.done != nil { + defer func() { + ui.done <- struct{}{} + }() + } + + var buff bytes.Buffer + + buff.Write([]byte(`[1,2,{"progname":"gdu","progver":"`)) + buff.Write([]byte(build.Version)) + buff.Write([]byte(`","timestamp":`)) + buff.Write([]byte(strconv.FormatInt(time.Now().Unix(), 10))) + buff.Write([]byte("},\n")) + + file, err := os.Create(ui.exportName) + if err != nil { + ui.showErrFromGo("Error creating file", err) + return + } + + if err = ui.topDir.EncodeJSON(&buff, true); err != nil { + ui.showErrFromGo("Error encoding JSON", err) + return + } + + if _, err = buff.Write([]byte("]\n")); err != nil { + ui.showErrFromGo("Error writing to buffer", err) + return + } + if _, err = buff.WriteTo(file); err != nil { + ui.showErrFromGo("Error writing to file", err) + return + } + }() +} + +func (ui *UI) isInArchive() bool { + if ui.currentDir == nil { + return false + } + _, ok := ui.currentDir.(*analyze.ZipDir) + return ok +} diff --git a/tui/actions_linux_test.go b/tui/actions_linux_test.go new file mode 100644 index 0000000..8c51893 --- /dev/null +++ b/tui/actions_linux_test.go @@ -0,0 +1,24 @@ +//go:build linux + +package tui + +import ( + "bytes" + "testing" + + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/stretchr/testify/assert" +) + +func TestShowDevicesWithError(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + getter := device.LinuxDevicesInfoGetter{MountsPath: "/xyzxyz"} + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + err := ui.ListDevices(getter) + + assert.Contains(t, err.Error(), "no such file") +} diff --git a/tui/actions_test.go b/tui/actions_test.go new file mode 100644 index 0000000..928faa8 --- /dev/null +++ b/tui/actions_test.go @@ -0,0 +1,512 @@ +package tui + +import ( + "bytes" + "errors" + "os" + "slices" + "testing" + + "github.com/dundee/gdu/v5/internal/testanalyze" + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/stretchr/testify/assert" +) + +func TestShowDevices(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + + ui.table.Draw(simScreen) + simScreen.Show() + + b, _, _ := simScreen.GetContents() + + text := []byte("Device name") + for i, r := range b[0:11] { + assert.Equal(t, text[i], r.Bytes[0]) + } +} + +func TestShowDevicesBW(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + + ui.table.Draw(simScreen) + simScreen.Show() + + b, _, _ := simScreen.GetContents() + + text := []byte("Device name") + for i, r := range b[0:11] { + assert.Equal(t, text[i], r.Bytes[0]) + } +} + +func TestDeviceSelected(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + ui.UseOldSizeBar() + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + assert.Equal(t, 3, ui.table.GetRowCount()) + + ui.deviceItemSelected(1, 0) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") +} + +func TestNilDeviceSelected(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + ui.UseOldSizeBar() + ui.SetIgnoreDirPaths([]string{"/xxx"}) + + ui.deviceItemSelected(1, 0) + + assert.Equal(t, 0, ui.table.GetRowCount()) +} + +func TestAnalyzePath(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") +} + +func TestAnalyzePathBW(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") +} + +func TestAnalyzePathWithParentDir(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, true, true) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.topDir = parentDir + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", parentDir) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, parentDir, ui.currentDir.GetParent()) + + assert.Equal(t, 5, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") +} + +func TestReadAnalysis(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) + ui.done = make(chan struct{}) + + err = ui.ReadAnalysis(input) + assert.Nil(t, err) + + <-ui.done // wait for reading + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "gdu", ui.currentDir.GetName()) +} + +func TestReadAnalysisWithWrongFile(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + input, err := os.OpenFile("../internal/testdata/wrong.json", os.O_RDONLY, 0o644) + assert.Nil(t, err) + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.done = make(chan struct{}) + + err = ui.ReadAnalysis(input) + assert.Nil(t, err) + + <-ui.done // wait for reading + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) +} + +func TestViewDirContents(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + res := ui.showFile() // selected item is dir, do nothing + assert.Nil(t, res) +} + +func TestViewFileWithoutCurrentDir(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + + res := ui.showFile() // no current directory + assert.Nil(t, res) +} + +func TestViewContentsOfNotExistingFile(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(3, 0) + + selectedFile := ui.table.GetCell(3, 0).GetReference().(fs.Item) + assert.Equal(t, "ddd", selectedFile.GetName()) + + res := ui.showFile() + assert.Nil(t, res) +} + +func TestViewFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(2, 0) + + file := ui.showFile() + assert.True(t, ui.pages.HasPage("file")) + + event := file.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, 'j', 0)) + assert.Equal(t, 'j', event.Rune()) +} + +func TestChangeCwd(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + cwd := "" + + opt := func(ui *UI) { + ui.SetChangeCwdFn(func(p string) error { + cwd = p + return nil + }) + } + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, opt) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(1, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + + assert.Equal(t, cwd, "test_dir/nested/subnested") +} + +func TestChangeCwdWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + cwd := "" + + opt := func(ui *UI) { + ui.SetChangeCwdFn(func(p string) error { + cwd = p + return errors.New("failed") + }) + } + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, opt) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(1, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + + assert.Equal(t, cwd, "test_dir/nested/subnested") +} + +func TestShowInfo(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + + assert.True(t, ui.pages.HasPage("info")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + + assert.False(t, ui.pages.HasPage("info")) +} + +func TestShowInfoBW(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + + assert.True(t, ui.pages.HasPage("info")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + + assert.False(t, ui.pages.HasPage("info")) +} + +func TestShowInfoWithHardlinks(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + files := slices.Collect(ui.currentDir.GetFiles(fs.SortByName, fs.SortAsc)) + nested := files[0].(*analyze.Dir) + subnested := nested.Files[1].(*analyze.Dir) + file := subnested.Files[0].(*analyze.File) + file2 := nested.Files[0].(*analyze.File) + file.Mli = 1 + file2.Mli = 1 + + ui.currentDir.UpdateStats(ui.linkedItems) + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(1, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(1, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + + assert.True(t, ui.pages.HasPage("info")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + + assert.False(t, ui.pages.HasPage("info")) +} + +func TestShowInfoWithoutCurrentDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + + // pressing `i` will do nothing + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + assert.False(t, ui.pages.HasPage("info")) +} + +func TestExitViewFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(2, 0) + + file := ui.showFile() + + assert.True(t, ui.pages.HasPage("file")) + + file.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + + assert.False(t, ui.pages.HasPage("file")) +} diff --git a/tui/background.go b/tui/background.go new file mode 100644 index 0000000..7d529cf --- /dev/null +++ b/tui/background.go @@ -0,0 +1,99 @@ +package tui + +import ( + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/rivo/tview" +) + +func (ui *UI) queueForDeletion(items []fs.Item, shouldEmpty bool) { + go func() { + for _, item := range items { + ui.deleteQueue <- deleteQueueItem{item: item, shouldEmpty: shouldEmpty} + } + }() + + ui.markedRows = make(map[int]struct{}) +} + +func (ui *UI) deleteWorker() { + defer func() { + if r := recover(); r != nil { + ui.app.Stop() + panic(r) + } + }() + + for item := range ui.deleteQueue { + ui.deleteItem(item.item, item.shouldEmpty) + } +} + +func (ui *UI) deleteItem(item fs.Item, shouldEmpty bool) { + ui.increaseActiveWorkers() + defer ui.decreaseActiveWorkers() + + var action, acting string + if shouldEmpty { + action = actionEmpty + } else { + action = actionDelete + } + + var deleteFun func(fs.Item, fs.Item) error + if shouldEmpty && !item.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + + var parentDir fs.Item + var deleteItems []fs.Item + if shouldEmpty && item.IsDir() { + parentDir = item + for file := range item.GetFilesLocked(fs.SortBySize, fs.SortDesc) { + deleteItems = append(deleteItems, file) + } + } else { + parentDir = ui.currentDir + deleteItems = append(deleteItems, item) + } + + for _, toDelete := range deleteItems { + if err := deleteFun(parentDir, toDelete); err != nil { + msg := "Can't " + action + " " + tview.Escape(toDelete.GetName()) + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.showErr(msg, err) + }) + if ui.done != nil { + ui.done <- struct{}{} + } + return + } + } + + if item.GetParent().GetPath() == ui.currentDir.GetPath() { + ui.app.QueueUpdateDraw(func() { + row, _ := ui.table.GetSelection() + x, y := ui.table.GetOffset() + ui.showDir() + ui.table.Select(min(row, ui.table.GetRowCount()-1), 0) + ui.table.SetOffset(min(x, ui.table.GetRowCount()-1), y) + }) + } + if ui.done != nil { + ui.done <- struct{}{} + } +} + +func (ui *UI) increaseActiveWorkers() { + ui.workersMut.Lock() + defer ui.workersMut.Unlock() + ui.activeWorkers++ +} + +func (ui *UI) decreaseActiveWorkers() { + ui.workersMut.Lock() + defer ui.workersMut.Unlock() + ui.activeWorkers-- +} diff --git a/tui/collapse_minimal_test.go b/tui/collapse_minimal_test.go new file mode 100644 index 0000000..0212013 --- /dev/null +++ b/tui/collapse_minimal_test.go @@ -0,0 +1,33 @@ +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCollapsedPathStruct(t *testing.T) { + // Test CollapsedPath struct creation and fields + cp := &CollapsedPath{ + DisplayName: "test/path", + DeepestDir: nil, + Segments: []string{"test", "path"}, + } + + assert.Equal(t, "test/path", cp.DisplayName) + assert.Nil(t, cp.DeepestDir) + assert.Equal(t, []string{"test", "path"}, cp.Segments) +} + +func TestFindCollapsedParentNilCases(t *testing.T) { + // Test nil input + result := findCollapsedParent(nil) + assert.Nil(t, result) +} + +// Test that our new functions exist and don't panic with basic inputs +func TestFunctionExistence(t *testing.T) { + // Test that findCollapsiblePath exists and handles nil gracefully + result := findCollapsiblePath(nil) + assert.Nil(t, result) +} diff --git a/tui/collapse_test.go b/tui/collapse_test.go new file mode 100644 index 0000000..0e41285 --- /dev/null +++ b/tui/collapse_test.go @@ -0,0 +1,303 @@ +package tui + +import ( + "bytes" + "testing" + + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestFindCollapsiblePath(t *testing.T) { + // Test case 1: Non-directory item should return nil + file := &analyze.File{ + Name: "test.txt", + } + result := findCollapsiblePath(file) + assert.Nil(t, result) + + // Test case 2: Directory with files and subdirectories should not collapse + dirWithFiles := &analyze.Dir{ + File: &analyze.File{ + Name: "mixed", + }, + Files: []fs.Item{ + &analyze.Dir{ + File: &analyze.File{ + Name: "subdir", + }, + Files: []fs.Item{}, + }, + &analyze.File{ + Name: "file.txt", + }, + }, + } + result = findCollapsiblePath(dirWithFiles) + assert.Nil(t, result) + + // Test case 3: Directory with multiple subdirectories should not collapse + dirWithMultiSubs := &analyze.Dir{ + File: &analyze.File{ + Name: "multi", + }, + Files: []fs.Item{ + &analyze.Dir{ + File: &analyze.File{ + Name: "subdir1", + }, + Files: []fs.Item{}, + }, + &analyze.Dir{ + File: &analyze.File{ + Name: "subdir2", + }, + Files: []fs.Item{}, + }, + }, + } + result = findCollapsiblePath(dirWithMultiSubs) + assert.Nil(t, result) + + // Test case 4: Single subdirectory chain should collapse + deepestDir := &analyze.Dir{ + File: &analyze.File{ + Name: "deep", + }, + Files: []fs.Item{ + &analyze.File{ + Name: "finalfile.txt", + }, + }, + } + + middleDir := &analyze.Dir{ + File: &analyze.File{ + Name: "middle", + }, + Files: []fs.Item{deepestDir}, + } + + rootDir := &analyze.Dir{ + File: &analyze.File{ + Name: "root", + }, + Files: []fs.Item{middleDir}, + } + + result = findCollapsiblePath(rootDir) + assert.NotNil(t, result) + assert.Equal(t, "root/middle/deep", result.DisplayName) + assert.Equal(t, deepestDir, result.DeepestDir) + assert.Equal(t, []string{"middle", "deep"}, result.Segments) + + // Test case 5: Directory with no subdirectories should not collapse + emptyDir := &analyze.Dir{ + File: &analyze.File{ + Name: "empty", + }, + Files: []fs.Item{}, + } + result = findCollapsiblePath(emptyDir) + assert.Nil(t, result) +} + +func TestFindCollapsedParent(t *testing.T) { + // Test case 1: Nil current directory + result := findCollapsedParent(nil) + assert.Nil(t, result) + + // Test case 2: Directory without parent + rootDir := &analyze.Dir{ + File: &analyze.File{ + Name: "root", + }, + Files: []fs.Item{}, + } + result = findCollapsedParent(rootDir) + assert.Nil(t, result) + + // Test case 3: Directory in a collapsed chain + otherDir := &analyze.Dir{ + File: &analyze.File{ + Name: "other", + }, + Files: []fs.Item{}, + } + + grandParent := &analyze.Dir{ + File: &analyze.File{ + Name: "grandparent", + }, + Files: []fs.Item{otherDir}, + } + otherDir.SetParent(grandParent) + + parent := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: []fs.Item{}, + } + parent.SetParent(grandParent) + grandParent.AddFile(parent) + + child := &analyze.Dir{ + File: &analyze.File{ + Name: "child", + }, + Files: []fs.Item{}, + } + child.SetParent(parent) + parent.AddFile(child) + + result = findCollapsedParent(child) + assert.Equal(t, grandParent, result) + + // Test case 4: Directory not in a collapsed chain + normalParent := &analyze.Dir{ + File: &analyze.File{ + Name: "normalparent", + }, + Files: []fs.Item{ + &analyze.File{ + Name: "file.txt", + }, + }, + } + + normalChild := &analyze.Dir{ + File: &analyze.File{ + Name: "normalchild", + }, + Files: []fs.Item{}, + } + normalChild.SetParent(normalParent) + normalParent.AddFile(normalChild) + + result = findCollapsedParent(normalChild) + assert.Equal(t, normalParent, result) +} + +func TestFormatCollapsedRow(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false) + + // Create a test collapsed path + deepDir := &analyze.Dir{ + File: &analyze.File{ + Name: "deep", + Size: 1000, + Usage: 800, + }, + Files: []fs.Item{}, + } + + collapsedPath := &CollapsedPath{ + DisplayName: "level1/level2/deep", + DeepestDir: deepDir, + Segments: []string{"level1", "level2", "deep"}, + } + + // Test normal formatting + result := ui.formatCollapsedRow(collapsedPath, 1000, 1000, false, false) + assert.Contains(t, result, "level1/level2/deep") + assert.Contains(t, result, "/") // Should have directory indicator + + // Test with marked flag + ui.markedRows = map[int]struct{}{0: {}} + result = ui.formatCollapsedRow(collapsedPath, 1000, 1000, true, false) + assert.Contains(t, result, "✓") // Should have marked indicator + + // Test with ignored flag + result = ui.formatCollapsedRow(collapsedPath, 1000, 1000, false, true) + assert.Contains(t, result, "level1/level2/deep") + + // Test with ShowApparentSize + ui.ShowApparentSize = true + result = ui.formatCollapsedRow(collapsedPath, 1000, 1000, false, false) + assert.Contains(t, result, "level1/level2/deep") + + // Test with showItemCount + ui.showItemCount = true + result = ui.formatCollapsedRow(collapsedPath, 1000, 1000, false, false) + assert.Contains(t, result, "level1/level2/deep") + + // Test with showMtime + ui.showMtime = true + result = ui.formatCollapsedRow(collapsedPath, 1000, 1000, false, false) + assert.Contains(t, result, "level1/level2/deep") + + // Test without colors + ui2 := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + result = ui2.formatCollapsedRow(collapsedPath, 1000, 1000, false, false) + assert.Contains(t, result, "level1/level2/deep") +} + +func TestCollapsedPathIntegration(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false) + + // Create a directory structure that should be collapsed + deepestDir := &analyze.Dir{ + File: &analyze.File{ + Name: "deepest", + Size: 100, + Usage: 80, + }, + Files: []fs.Item{ + &analyze.File{ + Name: "file.txt", + Size: 50, + Usage: 40, + }, + }, + } + + middleDir := &analyze.Dir{ + File: &analyze.File{ + Name: "middle", + Size: 100, + Usage: 80, + }, + Files: []fs.Item{deepestDir}, + } + + topDir := &analyze.Dir{ + File: &analyze.File{ + Name: "top", + Size: 100, + Usage: 80, + }, + Files: []fs.Item{middleDir}, + } + + deepestDir.SetParent(middleDir) + middleDir.SetParent(topDir) + + ui.currentDir = topDir + ui.topDir = topDir + ui.topDirPath = "/test" + ui.currentDirPath = "/test" + ui.SetCollapsePath(true) + + // Test that showDir properly handles collapsed paths + ui.showDir() + + // Test navigation into collapsed path + ui.table.Select(1, 0) // Select the collapsed entry + cell := ui.table.GetCell(1, 0) + assert.NotNil(t, cell) + + ref := cell.GetReference() + assert.NotNil(t, ref) + assert.Equal(t, deepestDir, ref) // Should reference the deepest directory +} diff --git a/tui/exec.go b/tui/exec.go new file mode 100644 index 0000000..24c3e11 --- /dev/null +++ b/tui/exec.go @@ -0,0 +1,18 @@ +package tui + +import ( + "os" + "os/exec" +) + +// Execute runs given bin path via exec.Command call +func Execute(argv0 string, argv, envv []string) error { + cmd := exec.Command(argv0, argv...) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Env = envv + + return cmd.Run() +} diff --git a/tui/exec_other.go b/tui/exec_other.go new file mode 100644 index 0000000..bdba7b1 --- /dev/null +++ b/tui/exec_other.go @@ -0,0 +1,47 @@ +//go:build !windows + +package tui + +import ( + "os" + "os/signal" + "syscall" +) + +func getShellBin() string { + shellbin, ok := os.LookupEnv("SHELL") + if !ok { + shellbin = "/bin/bash" + } + return shellbin +} + +func (ui *UI) spawnShell() { + if ui.currentDir == nil { + return + } + + ui.app.Suspend(func() { + if err := os.Chdir(ui.currentDirPath); err != nil { + ui.showErr("Error changing directory", err) + return + } + + if err := ui.exec(getShellBin(), nil, os.Environ()); err != nil { + ui.showErr("Error executing shell", err) + } + }) +} + +func stopProcess() error { + // chan for signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGCONT) + defer signal.Stop(sigChan) + + err := syscall.Kill(syscall.Getpid(), syscall.SIGTSTP) + // wait continue + <-sigChan + + return err +} diff --git a/tui/exec_test.go b/tui/exec_test.go new file mode 100644 index 0000000..72393ae --- /dev/null +++ b/tui/exec_test.go @@ -0,0 +1,13 @@ +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExecute(t *testing.T) { + err := Execute("true", []string{}, []string{}) + + assert.Nil(t, err) +} diff --git a/tui/exec_windows.go b/tui/exec_windows.go new file mode 100644 index 0000000..93eb31b --- /dev/null +++ b/tui/exec_windows.go @@ -0,0 +1,33 @@ +package tui + +import ( + "os" +) + +func getShellBin() string { + shellbin, ok := os.LookupEnv("COMSPEC") + if !ok { + shellbin = "C:\\WINDOWS\\System32\\cmd.exe" + } + return shellbin +} + +func (ui *UI) spawnShell() { + if ui.currentDir == nil { + return + } + + ui.app.Stop() + + if err := os.Chdir(ui.currentDirPath); err != nil { + ui.showErr("Error changing directory", err) + return + } + if err := ui.exec(getShellBin(), nil, os.Environ()); err != nil { + ui.showErr("Error executing shell", err) + } +} + +func stopProcess() error { + return nil +} diff --git a/tui/export_test.go b/tui/export_test.go new file mode 100644 index 0000000..a47b465 --- /dev/null +++ b/tui/export_test.go @@ -0,0 +1,203 @@ +package tui + +import ( + "bytes" + "os" + "testing" + + "github.com/dundee/gdu/v5/internal/testanalyze" + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +func TestConfirmExport(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'E', 0)) + + assert.True(t, ui.pages.HasPage("export")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'n', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyEnter, 0, 0)) + + assert.True(t, ui.pages.HasPage("export")) +} + +func TestExportAnalysis(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + ui.exportAnalysis() + + assert.True(t, ui.pages.HasPage("exporting")) + + <-ui.done + + assert.FileExists(t, "export.json") + err := os.Remove("export.json") + assert.NoError(t, err) + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } +} + +func TestExportAnalysisEsc(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + form := ui.confirmExport() + formInputFn := form.GetInputCapture() + + assert.True(t, ui.pages.HasPage("export")) + + formInputFn(tcell.NewEventKey(tcell.KeyEsc, 0, 0)) + + assert.False(t, ui.pages.HasPage("export")) +} + +func TestExportAnalysisWithName(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + form := ui.confirmExport() + // formInputFn := form.GetInputCapture() + item := form.GetFormItemByLabel("File name") + inputFn := item.(*tview.InputField).InputHandler() + + // send 'n' to input + inputFn(tcell.NewEventKey(tcell.KeyRune, 'n', 0), nil) + assert.Equal(t, "export.jsonn", ui.exportName) + + assert.True(t, ui.pages.HasPage("export")) + + form.GetButton(0).InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, 0), nil) + + assert.True(t, ui.pages.HasPage("exporting")) + + <-ui.done + + assert.FileExists(t, "export.jsonn") + err := os.Remove("export.jsonn") + assert.NoError(t, err) + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } +} + +func TestExportAnalysisWithoutRights(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + _, err := os.Create("export.json") + assert.NoError(t, err) + err = os.Chmod("export.json", 0) + assert.NoError(t, err) + defer func() { + err = os.Chmod("export.json", 0o755) + assert.Nil(t, err) + err = os.Remove("export.json") + assert.NoError(t, err) + }() + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + ui.exportAnalysis() + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) +} diff --git a/tui/filter.go b/tui/filter.go new file mode 100644 index 0000000..5276791 --- /dev/null +++ b/tui/filter.go @@ -0,0 +1,142 @@ +package tui + +import ( + "path/filepath" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) rebuildFooter() { + ui.footer.Clear() + if ui.filteringInput != nil { + ui.footer.AddItem(ui.filteringInput, 0, 1, ui.filtering) + } + if ui.typeFilteringInput != nil { + ui.footer.AddItem(ui.typeFilteringInput, 0, 1, ui.typeFiltering) + } + ui.footer.AddItem(ui.footerLabel, 0, 5, false) +} + +func (ui *UI) hideFilterInput() { + ui.filterValue = "" + ui.filteringInput = nil + ui.filtering = false + ui.rebuildFooter() + ui.app.SetFocus(ui.table) +} + +func (ui *UI) showFilterInput() { + if ui.currentDir == nil { + return + } + + if ui.filteringInput == nil { + ui.markedRows = make(map[int]struct{}) + + ui.filteringInput = tview.NewInputField() + ui.filteringInput.SetLabel("Name: ") + + if !ui.UseColors { + ui.filteringInput.SetFieldBackgroundColor( + tcell.NewRGBColor(100, 100, 100), + ) + ui.filteringInput.SetFieldTextColor( + tcell.NewRGBColor(255, 255, 255), + ) + } + + ui.filteringInput.SetChangedFunc(func(text string) { + ui.filterValue = text + ui.showDir() + }) + ui.filteringInput.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyESC { + ui.hideFilterInput() + ui.showDir() + } else { + ui.app.SetFocus(ui.table) + ui.filtering = false + } + }) + + ui.rebuildFooter() + } + ui.app.SetFocus(ui.filteringInput) + ui.filtering = true +} + +func (ui *UI) hideTypeFilterInput() { + ui.typeFilterValue = "" + ui.typeFilteringInput = nil + ui.typeFiltering = false + ui.rebuildFooter() + ui.app.SetFocus(ui.table) +} + +func (ui *UI) showTypeFilterInput() { + if ui.currentDir == nil { + return + } + + if ui.typeFilteringInput == nil { + ui.markedRows = make(map[int]struct{}) + + ui.typeFilteringInput = tview.NewInputField() + ui.typeFilteringInput.SetLabel("Type: ") + + if !ui.UseColors { + ui.typeFilteringInput.SetFieldBackgroundColor( + tcell.NewRGBColor(100, 100, 100), + ) + ui.typeFilteringInput.SetFieldTextColor( + tcell.NewRGBColor(255, 255, 255), + ) + } + + ui.typeFilteringInput.SetChangedFunc(func(text string) { + ui.typeFilterValue = text + ui.showDir() + }) + ui.typeFilteringInput.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyESC { + ui.hideTypeFilterInput() + ui.showDir() + } else { + ui.app.SetFocus(ui.table) + ui.typeFiltering = false + } + }) + + ui.rebuildFooter() + } + ui.app.SetFocus(ui.typeFilteringInput) + ui.typeFiltering = true +} + +// matchesTypeFilter returns true if the file name matches the type filter. +// Directories always match. Files are matched by extension against the +// comma-separated list in typeFilterValue. +func (ui *UI) matchesTypeFilter(name string, isDir bool) bool { + if ui.typeFilterValue == "" { + return true + } + if isDir { + return true + } + + ext := strings.ToLower(filepath.Ext(name)) + if ext == "" { + return false + } + ext = strings.TrimPrefix(ext, ".") + + for _, t := range strings.Split(ui.typeFilterValue, ",") { + t = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(t), ".")) + if t != "" && t == ext { + return true + } + } + return false +} diff --git a/tui/filter_test.go b/tui/filter_test.go new file mode 100644 index 0000000..afd790e --- /dev/null +++ b/tui/filter_test.go @@ -0,0 +1,480 @@ +package tui + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/dundee/gdu/v5/internal/testanalyze" + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +func TestFiltering(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + // mark the item for deletion + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + assert.Equal(t, 1, len(ui.markedRows)) + + ui.showFilterInput() + ui.filterValue = "" + ui.showDir() + + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") // nothing is filtered + // marking should be dropped after sorting + assert.Equal(t, 0, len(ui.markedRows)) + + ui.filterValue = "aa" + ui.showDir() + + assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") // shows only cccc + + ui.hideFilterInput() + ui.showDir() + + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") // filtering reset +} + +func TestFilteringWithoutCurrentDir(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + + ui.showFilterInput() + + assert.False(t, ui.filtering) +} + +func TestSwitchToTable(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '/', 0)) // open filtering input + handler := ui.filteringInput.InputHandler() + handler(tcell.NewEventKey(tcell.KeyRune, 'n', 0), func(p tview.Primitive) {}) + handler(tcell.NewEventKey(tcell.KeyRune, 'e', 0), func(p tview.Primitive) {}) + handler(tcell.NewEventKey(tcell.KeyRune, 's', 0), func(p tview.Primitive) {}) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // we are filtering, should do nothing + + assert.Contains(t, ui.table.GetCell(0, 0).Text, "nested") + + handler( + tcell.NewEventKey(tcell.KeyTAB, ' ', 0), func(p tview.Primitive) {}, + ) // switch focus to table + ui.keyPressed(tcell.NewEventKey(tcell.KeyTAB, ' ', 0)) // switch back to input + handler( + tcell.NewEventKey(tcell.KeyEnter, ' ', 0), func(p tview.Primitive) {}, + ) // switch back to table + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // open nested dir + + assert.Contains(t, ui.table.GetCell(1, 0).Text, "subnested") + assert.Empty(t, ui.filterValue) // filtering reset +} + +func TestExitFiltering(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '/', 0)) // open filtering input + handler := ui.filteringInput.InputHandler() + ui.filterValue = "xxx" + ui.showDir() + + assert.Equal(t, ui.table.GetCell(0, 0).Text, "") // nothing is filtered + + handler( + tcell.NewEventKey(tcell.KeyEsc, ' ', 0), func(p tview.Primitive) {}, + ) // exit filtering + + assert.Contains(t, ui.table.GetCell(0, 0).Text, "nested") + assert.Empty(t, ui.filterValue) // filtering reset +} + +func createDirWithExtensions() *analyze.Dir { + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + Usage: 1e9, + Size: 1e9, + Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), + }, + BasePath: ".", + ItemCount: 6, + } + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "subdir", + Usage: 1e6, + Size: 1e6, + Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), + Parent: dir, + }, + } + goFile := &analyze.File{ + Name: "main.go", + Usage: 1e6, + Size: 1e6, + Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), + Parent: dir, + } + yamlFile := &analyze.File{ + Name: "config.yaml", + Usage: 1e3, + Size: 1e3, + Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), + Parent: dir, + } + jsonFile := &analyze.File{ + Name: "data.json", + Usage: 1e4, + Size: 1e4, + Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), + Parent: dir, + } + noExtFile := &analyze.File{ + Name: "Makefile", + Usage: 500, + Size: 500, + Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), + Parent: dir, + } + dir.Files = fs.Files{subdir, goFile, yamlFile, jsonFile, noExtFile} + return dir +} + +func TestTypeFiltering(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + dir := createDirWithExtensions() + ui.currentDir = dir + ui.topDir = dir + ui.topDirPath = dir.GetPath() + ui.showDir() + + rowCount := ui.table.GetRowCount() + assert.Equal(t, 5, rowCount) // subdir + main.go + config.yaml + data.json + Makefile + + // activate type filter for "go" files + ui.showTypeFilterInput() + assert.True(t, ui.typeFiltering) + + ui.typeFilterValue = "go" + ui.showDir() + + // should show: subdir (dirs always shown) + main.go + assert.True(t, tableContains(ui, "subdir")) + assert.True(t, tableContains(ui, "main.go")) + assert.False(t, tableContains(ui, "config.yaml")) + assert.False(t, tableContains(ui, "data.json")) + assert.False(t, tableContains(ui, "Makefile")) + + ui.typeFilterValue = "go,yaml" + ui.showDir() + + assert.True(t, tableContains(ui, "subdir")) + assert.True(t, tableContains(ui, "main.go")) + assert.True(t, tableContains(ui, "config.yaml")) + assert.False(t, tableContains(ui, "data.json")) + + // hide type filter resets it + ui.hideTypeFilterInput() + ui.showDir() + + assert.True(t, tableContains(ui, "main.go")) + assert.True(t, tableContains(ui, "config.yaml")) + assert.True(t, tableContains(ui, "data.json")) + assert.True(t, tableContains(ui, "Makefile")) + assert.Empty(t, ui.typeFilterValue) +} + +func TestTypeFilteringWithoutCurrentDir(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + ui.showTypeFilterInput() + + assert.False(t, ui.typeFiltering) +} + +func TestTypeFilteringKeyBinding(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'T', 0)) + + assert.True(t, ui.typeFiltering) + assert.NotNil(t, ui.typeFilteringInput) +} + +func TestExitTypeFiltering(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'T', 0)) + handler := ui.typeFilteringInput.InputHandler() + ui.typeFilterValue = "go" + ui.showDir() + + handler( + tcell.NewEventKey(tcell.KeyEsc, ' ', 0), func(p tview.Primitive) {}, + ) + + assert.Empty(t, ui.typeFilterValue) + assert.Nil(t, ui.typeFilteringInput) + assert.False(t, ui.typeFiltering) +} + +func TestTypeFilterTabSwitch(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + // open type filter, confirm with Enter, then TAB should switch back + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'T', 0)) + assert.True(t, ui.typeFiltering) + + handler := ui.typeFilteringInput.InputHandler() + handler( + tcell.NewEventKey(tcell.KeyEnter, ' ', 0), func(p tview.Primitive) {}, + ) + assert.False(t, ui.typeFiltering) // focus returned to table + + ui.keyPressed(tcell.NewEventKey(tcell.KeyTAB, ' ', 0)) + assert.True(t, ui.typeFiltering) // TAB should switch back to type filter +} + +func TestBothFiltersCoexist(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + dir := createDirWithExtensions() + ui.currentDir = dir + ui.topDir = dir + ui.topDirPath = dir.GetPath() + + // activate both filters + ui.showFilterInput() + ui.filterValue = "main" + ui.showTypeFilterInput() + ui.typeFilterValue = "go" + ui.showDir() + + assert.True(t, tableContains(ui, "main.go")) // matches both name "main" and type "go" + assert.False(t, tableContains(ui, "subdir")) // dir name doesn't contain "main" + assert.False(t, tableContains(ui, "data.json")) // doesn't match name or type +} + +func TestMatchesTypeFilter(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + ui.typeFilterValue = "go" + assert.True(t, ui.matchesTypeFilter("main.go", false)) + assert.False(t, ui.matchesTypeFilter("config.yaml", false)) + assert.True(t, ui.matchesTypeFilter("subdir", true)) // dirs always match + assert.False(t, ui.matchesTypeFilter("Makefile", false)) // no extension + + ui.typeFilterValue = "go,yaml" + assert.True(t, ui.matchesTypeFilter("main.go", false)) + assert.True(t, ui.matchesTypeFilter("config.yaml", false)) + assert.False(t, ui.matchesTypeFilter("data.json", false)) + + ui.typeFilterValue = ".go" // with leading dot + assert.True(t, ui.matchesTypeFilter("main.go", false)) + + ui.typeFilterValue = "GO" // case insensitive + assert.True(t, ui.matchesTypeFilter("main.go", false)) + + ui.typeFilterValue = "" // empty filter matches all + assert.True(t, ui.matchesTypeFilter("anything", false)) +} + +func TestTypeFilterInputNoColorAndChangedCallback(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(80, 30) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + dir := createDirWithExtensions() + ui.currentDir = dir + ui.topDir = dir + ui.topDirPath = dir.GetPath() + ui.showDir() + + ui.showTypeFilterInput() + assert.NotNil(t, ui.typeFilteringInput) + + handler := ui.typeFilteringInput.InputHandler() + handler(tcell.NewEventKey(tcell.KeyRune, 'g', 0), func(p tview.Primitive) {}) + handler(tcell.NewEventKey(tcell.KeyRune, 'o', 0), func(p tview.Primitive) {}) + + assert.Equal(t, "go", ui.typeFilterValue) + assert.True(t, tableContains(ui, "main.go")) + assert.False(t, tableContains(ui, "config.yaml")) +} + +func TestTypeFilterShowAgainKeepsExistingInput(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(80, 30) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + dir := createDirWithExtensions() + ui.currentDir = dir + ui.topDir = dir + ui.topDirPath = dir.GetPath() + ui.showDir() + + ui.showTypeFilterInput() + original := ui.typeFilteringInput + + ui.showTypeFilterInput() + + assert.Equal(t, original, ui.typeFilteringInput) + assert.True(t, ui.typeFiltering) +} + +func collectTableTexts(ui *UI) []string { + var texts []string + for i := 0; i < ui.table.GetRowCount(); i++ { + cell := ui.table.GetCell(i, 0) + if cell != nil { + texts = append(texts, cell.Text) + } + } + return texts +} + +func tableContains(ui *UI, name string) bool { + for _, text := range collectTableTexts(ui) { + if strings.Contains(text, name) { + return true + } + } + return false +} diff --git a/tui/format.go b/tui/format.go new file mode 100644 index 0000000..7d0c1b0 --- /dev/null +++ b/tui/format.go @@ -0,0 +1,279 @@ +package tui + +import ( + "fmt" + "math" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/rivo/tview" +) + +const ( + blackOnWhite = "[black:white:-]" + whiteOnBlack = "[white:black:-]" + + defaultColor = "[-::]" + defaultColorBold = "[::b]" +) + +func (ui *UI) formatFileRow(item fs.Item, maxUsage, maxSize int64, marked, ignored bool) string { + part := 0 + if !ignored { + if ui.ShowApparentSize { + if size := item.GetSize(); size > 0 { + part = int(float64(size) / float64(maxSize) * 100.0) + } + } else { + if usage := item.GetUsage(); usage > 0 { + part = int(float64(usage) / float64(maxUsage) * 100.0) + } + } + } + + row := string(item.GetFlag()) + + numberColor := fmt.Sprintf( + "[%s::b]", + ui.resultRow.NumberColor, + ) + + if ui.UseColors && !marked && !ignored { + row += numberColor + } else { + row += defaultColorBold + } + + if ui.ShowApparentSize { + row += fmt.Sprintf("%15s", ui.formatSize(item.GetSize(), false, true)) + } else { + row += fmt.Sprintf("%15s", ui.formatSize(item.GetUsage(), false, true)) + } + + if ui.useOldSizeBar { + row += " " + getUsageGraphOld(part) + " " + } else { + row += getUsageGraph(part) + } + + if ui.showItemCount { + if ui.UseColors && !marked && !ignored { + row += numberColor + } else { + row += defaultColorBold + } + + countToDisplay := item.GetItemCount() + if item.IsDir() { + countToDisplay-- + } + row += fmt.Sprintf("%11s ", ui.formatCount(countToDisplay)) + } + + if ui.showMtime { + if ui.UseColors && !marked && !ignored { + row += numberColor + } else { + row += defaultColorBold + } + row += fmt.Sprintf( + "%s "+defaultColor, + item.GetMtime().Format("2006-01-02 15:04:05"), + ) + } + + if len(ui.markedRows) > 0 { + if marked { + row += string('✓') + } else { + row += " " + } + row += " " + } + + if item.IsDir() { + if ui.UseColors && !marked && !ignored { + row += fmt.Sprintf("[%s::b]/", ui.resultRow.DirectoryColor) + } else { + row += defaultColorBold + "/" + } + } + row += tview.Escape(item.GetName()) + return row +} + +// formatCollapsedRow formats a collapsed directory path for display +func (ui *UI) formatCollapsedRow(collapsedPath *CollapsedPath, maxUsage, maxSize int64, marked, ignored bool) string { + // Use the deepest directory's stats for display + item := collapsedPath.DeepestDir + + part := 0 + if !ignored { + if ui.ShowApparentSize { + if size := item.GetSize(); size > 0 { + part = int(float64(size) / float64(maxSize) * 100.0) + } + } else { + if usage := item.GetUsage(); usage > 0 { + part = int(float64(usage) / float64(maxUsage) * 100.0) + } + } + } + + row := string(item.GetFlag()) + + numberColor := fmt.Sprintf( + "[%s::b]", + ui.resultRow.NumberColor, + ) + + if ui.UseColors && !marked && !ignored { + row += numberColor + } else { + row += defaultColorBold + } + + if ui.ShowApparentSize { + row += fmt.Sprintf("%15s", ui.formatSize(item.GetSize(), false, true)) + } else { + row += fmt.Sprintf("%15s", ui.formatSize(item.GetUsage(), false, true)) + } + + if ui.useOldSizeBar { + row += " " + getUsageGraphOld(part) + " " + } else { + row += getUsageGraph(part) + } + + if ui.showItemCount { + if ui.UseColors && !marked && !ignored { + row += numberColor + } else { + row += defaultColorBold + } + + countToDisplay := item.GetItemCount() + if item.IsDir() { + countToDisplay-- + } + row += fmt.Sprintf("%11s ", ui.formatCount(countToDisplay)) + } + + if ui.showMtime { + if ui.UseColors && !marked && !ignored { + row += numberColor + } else { + row += defaultColorBold + } + row += fmt.Sprintf( + "%s "+defaultColor, + item.GetMtime().Format("2006-01-02 15:04:05"), + ) + } + + if len(ui.markedRows) > 0 { + if marked { + row += string('✓') + } else { + row += " " + } + row += " " + } + + // Always display as directory with special formatting for collapsed path + if ui.UseColors && !marked && !ignored { + row += fmt.Sprintf("[%s::b]/", ui.resultRow.DirectoryColor) + } else { + row += defaultColorBold + "/" + } + + // Display the collapsed path (e.g., "a/b/c") + row += tview.Escape(collapsedPath.DisplayName) + return row +} + +func (ui *UI) formatSize(size int64, reverseColor, transparentBg bool) string { + var color string + if reverseColor { + if ui.UseColors { + color = fmt.Sprintf( + "[%s:%s:-]", + ui.footerTextColor, + ui.footerBackgroundColor, + ) + } else { + color = blackOnWhite + } + } else { + if transparentBg { + color = defaultColor + } else { + color = whiteOnBlack + } + } + + if ui.UseSIPrefix { + return formatWithDecPrefix(size, color) + } + return formatWithBinPrefix(float64(size), color) +} + +func (ui *UI) formatCount(count int64) string { + row := "" + color := defaultColor + count64 := float64(count) + + switch { + case count64 >= common.G: + row += fmt.Sprintf("%.1f%sG", float64(count)/float64(common.G), color) + case count64 >= common.M: + row += fmt.Sprintf("%.1f%sM", float64(count)/float64(common.M), color) + case count64 >= common.K: + row += fmt.Sprintf("%.1f%sk", float64(count)/float64(common.K), color) + default: + row += fmt.Sprintf("%d%s", count, color) + } + return row +} + +func formatWithBinPrefix(fsize float64, color string) string { + asize := math.Abs(fsize) + + switch { + case asize >= common.Ei: + return fmt.Sprintf("%.1f%s EiB", fsize/common.Ei, color) + case asize >= common.Pi: + return fmt.Sprintf("%.1f%s PiB", fsize/common.Pi, color) + case asize >= common.Ti: + return fmt.Sprintf("%.1f%s TiB", fsize/common.Ti, color) + case asize >= common.Gi: + return fmt.Sprintf("%.1f%s GiB", fsize/common.Gi, color) + case asize >= common.Mi: + return fmt.Sprintf("%.1f%s MiB", fsize/common.Mi, color) + case asize >= common.Ki: + return fmt.Sprintf("%.1f%s KiB", fsize/common.Ki, color) + default: + return fmt.Sprintf("%d%s B", int64(fsize), color) + } +} + +func formatWithDecPrefix(size int64, color string) string { + fsize := float64(size) + asize := math.Abs(fsize) + switch { + case asize >= common.E: + return fmt.Sprintf("%.1f%s EB", fsize/common.E, color) + case asize >= common.P: + return fmt.Sprintf("%.1f%s PB", fsize/common.P, color) + case asize >= common.T: + return fmt.Sprintf("%.1f%s TB", fsize/common.T, color) + case asize >= common.G: + return fmt.Sprintf("%.1f%s GB", fsize/common.G, color) + case asize >= common.M: + return fmt.Sprintf("%.1f%s MB", fsize/common.M, color) + case asize >= common.K: + return fmt.Sprintf("%.1f%s kB", fsize/common.K, color) + default: + return fmt.Sprintf("%d%s B", size, color) + } +} diff --git a/tui/format_test.go b/tui/format_test.go new file mode 100644 index 0000000..3b59cf2 --- /dev/null +++ b/tui/format_test.go @@ -0,0 +1,175 @@ +package tui + +import ( + "bytes" + "testing" + + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/stretchr/testify/assert" +) + +func TestFormatSize(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + + assert.Equal(t, "1[white:black:-] B", ui.formatSize(1, false, false)) + assert.Equal(t, "1.0[white:black:-] KiB", ui.formatSize(1<<10, false, false)) + assert.Equal(t, "1.0[white:black:-] MiB", ui.formatSize(1<<20, false, false)) + assert.Equal(t, "1.0[white:black:-] GiB", ui.formatSize(1<<30, false, false)) + assert.Equal(t, "1.0[white:black:-] TiB", ui.formatSize(1<<40, false, false)) + assert.Equal(t, "1.0[white:black:-] PiB", ui.formatSize(1<<50, false, false)) + assert.Equal(t, "1.0[white:black:-] EiB", ui.formatSize(1<<60, false, false)) + assert.Equal(t, "-1.0[white:black:-] KiB", ui.formatSize(-1<<10, false, false)) +} + +func TestFormatSizeDec(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, true) + + assert.Equal(t, "1[white:black:-] B", ui.formatSize(1, false, false)) + assert.Equal(t, "1.0[white:black:-] kB", ui.formatSize(1<<10, false, false)) + assert.Equal(t, "1.0[white:black:-] MB", ui.formatSize(1<<20, false, false)) + assert.Equal(t, "1.1[white:black:-] GB", ui.formatSize(1<<30, false, false)) + assert.Equal(t, "1.1[white:black:-] TB", ui.formatSize(1<<40, false, false)) + assert.Equal(t, "1.1[white:black:-] PB", ui.formatSize(1<<50, false, false)) + assert.Equal(t, "1.2[white:black:-] EB", ui.formatSize(1<<60, false, false)) + assert.Equal(t, "-1.0[white:black:-] kB", ui.formatSize(-1<<10, false, false)) +} + +func TestFormatCount(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + + assert.Equal(t, "1[-::]", ui.formatCount(1)) + assert.Equal(t, "1.0[-::]k", ui.formatCount(1<<10)) + assert.Equal(t, "1.0[-::]M", ui.formatCount(1<<20)) + assert.Equal(t, "1.1[-::]G", ui.formatCount(1<<30)) +} + +func TestEscapeName(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 10, + }, + } + + file := &analyze.File{ + Name: "Aaa [red] bbb", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "Aaa [red[] bbb") +} + +func TestMarked(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + ui.markedRows[0] = struct{}{} + ui.useOldSizeBar = true + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 10, + }, + } + + file := &analyze.File{ + Name: "Aaa", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), true, false), "✓ Aaa") + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "[##########] Aaa") +} + +func TestIgnored(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + ui.ignoredRows[0] = struct{}{} + ui.useOldSizeBar = true + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 10, + }, + } + + file := &analyze.File{ + Name: "Aaa", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, true), "[ ] Aaa") + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "[##########] Aaa") +} + +func TestSizeBar(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 10, + }, + } + + file := &analyze.File{ + Name: "Aaa", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "██████████▏Aaa") +} + +func TestOldSizeBar(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + ui.markedRows[0] = struct{}{} + ui.useOldSizeBar = true + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 20, + }, + } + + file := &analyze.File{ + Name: "Aaa", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, dir.GetUsage(), dir.GetSize(), false, false), "[##### ] Aaa") +} diff --git a/tui/keys.go b/tui/keys.go new file mode 100644 index 0000000..c4cf06b --- /dev/null +++ b/tui/keys.go @@ -0,0 +1,449 @@ +package tui + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +var analyzeParentPath = func(ui *UI, path string, parentDir fs.Item) error { + return ui.AnalyzePath(path, parentDir) +} + +func (ui *UI) keyPressed(key *tcell.EventKey) *tcell.EventKey { + if ui.handleCtrlZ(key) == nil { + return nil + } + + if ui.pages.HasPage("file") || ui.pages.HasPage("export") { + return key // send event to primitive + } + if ui.filtering || ui.typeFiltering { + return key + } + + key = ui.handleClosingModals(key) + if key == nil { + return nil + } + key = ui.handleInfoPageEvents(key) + if key == nil { + return nil + } + key = ui.handleQuit(key) + if key == nil { + return nil + } + + if ui.pages.HasPage("confirm") { + return ui.handleConfirmation(key) + } + + if ui.pages.HasPage("progress") || + ui.pages.HasPage("deleting") || + ui.pages.HasPage("emptying") { + return key + } + + key = ui.handleHelp(key) + if key == nil { + return nil + } + + if ui.pages.HasPage("help") { + return key + } + + key = ui.handleShell(key) + if key == nil { + return nil + } + + key = ui.handleLeftRight(key) + if key == nil { + return nil + } + + key = ui.handleFiltering(key) + if key == nil { + return nil + } + + return ui.handleMainActions(key) +} + +func (ui *UI) handleClosingModals(key *tcell.EventKey) *tcell.EventKey { + if key.Key() == tcell.KeyEsc || key.Rune() == 'q' { + if ui.pages.HasPage("help") { + ui.pages.RemovePage("help") + ui.app.SetFocus(ui.table) + return nil + } + if ui.pages.HasPage("info") { + ui.pages.RemovePage("info") + ui.app.SetFocus(ui.table) + return nil + } + } + return key +} + +func (ui *UI) handleConfirmation(key *tcell.EventKey) *tcell.EventKey { + if key.Rune() == 'h' { + return tcell.NewEventKey(tcell.KeyLeft, 0, 0) + } + if key.Rune() == 'l' { + return tcell.NewEventKey(tcell.KeyRight, 0, 0) + } + return key +} + +func (ui *UI) handleInfoPageEvents(key *tcell.EventKey) *tcell.EventKey { + if ui.pages.HasPage("info") { + switch key.Rune() { + case 'i': + ui.pages.RemovePage("info") + ui.app.SetFocus(ui.table) + return nil + case '?': + return nil + } + + if key.Key() == tcell.KeyUp || + key.Key() == tcell.KeyDown || + key.Rune() == 'j' || + key.Rune() == 'k' { + row, column := ui.table.GetSelection() + if (key.Key() == tcell.KeyUp || key.Rune() == 'k') && row > 0 { + row-- + } else if (key.Key() == tcell.KeyDown || key.Rune() == 'j') && + row+1 < ui.table.GetRowCount() { + row++ + } + ui.table.Select(row, column) + } + ui.showInfo() // refresh file info after any change + } + return key +} + +// handle ctrl+z job control +func (ui *UI) handleCtrlZ(key *tcell.EventKey) *tcell.EventKey { + if key.Key() == tcell.KeyCtrlZ { + ui.app.Suspend(func() { + termApp := ui.app.(*tview.Application) + termApp.Lock() + defer termApp.Unlock() + + err := stopProcess() + if err != nil { + ui.showErr("Error sending STOP signal", err) + } + }) + return nil + } + + return key +} + +func (ui *UI) handleQuit(key *tcell.EventKey) *tcell.EventKey { + switch key.Rune() { + case 'Q': + ui.app.Stop() + fmt.Fprintf(ui.output, "%s\n", ui.currentDirPath) + return nil + case 'q': + ui.app.Stop() + return nil + } + return key +} + +func (ui *UI) handleHelp(key *tcell.EventKey) *tcell.EventKey { + if key.Rune() == '?' { + if ui.pages.HasPage("help") { + ui.pages.RemovePage("help") + ui.app.SetFocus(ui.table) + return nil + } + ui.showHelp() + return nil + } + return key +} + +func (ui *UI) handleShell(key *tcell.EventKey) *tcell.EventKey { + if key.Rune() == 'b' { + if ui.isInArchive() { + ui.showErr("Spawning shell is not supported in archives", nil) + return nil + } + if ui.noSpawnShell { + previousHeaderText := ui.header.GetText(false) + + // show feedback to user + ui.header.SetText(" Shell spawning is disabled!") + + go func() { + time.Sleep(2 * time.Second) + ui.app.QueueUpdateDraw(func() { + ui.header.Clear() + ui.header.SetText(previousHeaderText) + }) + }() + + return nil + } + ui.spawnShell() + return nil + } + return key +} + +func (ui *UI) handleLeftRight(key *tcell.EventKey) *tcell.EventKey { + if key.Rune() == 'h' || key.Key() == tcell.KeyLeft { + ui.handleLeft() + return nil + } + + if key.Rune() == 'l' || key.Key() == tcell.KeyRight { + ui.handleRight() + return nil + } + return key +} + +func (ui *UI) handleFiltering(key *tcell.EventKey) *tcell.EventKey { + if key.Key() != tcell.KeyTab { + return key + } + if ui.filteringInput != nil { + ui.filtering = true + ui.app.SetFocus(ui.filteringInput) + return nil + } + if ui.typeFilteringInput != nil { + ui.typeFiltering = true + ui.app.SetFocus(ui.typeFilteringInput) + return nil + } + return key +} + +// nolint: funlen // Why: there's a lot of options to handle +func (ui *UI) handleMainActions(key *tcell.EventKey) *tcell.EventKey { + switch key.Rune() { + case 'd': + if ui.isInArchive() { + ui.showErr("Deletion is not supported in archives", nil) + return nil + } + ui.handleDelete(false) + case 'e': + if ui.isInArchive() { + ui.showErr("Deletion is not supported in archives", nil) + return nil + } + ui.handleDelete(true) + case 'v': + if ui.isInArchive() { + ui.showErr("Viewing content is not supported in archives", nil) + return nil + } + if ui.noViewFile { + previousHeaderText := ui.header.GetText(false) + + ui.header.SetText(" Viewing files is disabled!") + + go func() { + time.Sleep(2 * time.Second) + ui.app.QueueUpdateDraw(func() { + ui.header.Clear() + ui.header.SetText(previousHeaderText) + }) + }() + + return nil + } + ui.showFile() + case 'o': + if ui.noSpawnShell { + previousHeaderText := ui.header.GetText(false) + + // show feedback to user + ui.header.SetText(" Opening items is disabled!") + + go func() { + time.Sleep(2 * time.Second) + ui.app.QueueUpdateDraw(func() { + ui.header.Clear() + ui.header.SetText(previousHeaderText) + }) + }() + return nil + } + ui.openItem() + case 'i': + ui.showInfo() + case 'a', 'B', 'c', 'm': + ui.handleToggles(key) + case 'r': + if ui.currentDir != nil { + ui.rescanDir() + } + case 'E': + ui.confirmExport() + return nil + case 's', 'C', 'n', 'M': + ui.handleSorting(key) + case '/': + ui.showFilterInput() + return nil + case 'T': + ui.showTypeFilterInput() + return nil + case ' ': + ui.handleMark() + case 'I': + ui.ignoreItem() + } + return key +} + +func (ui *UI) handleToggles(key *tcell.EventKey) { + switch key.Rune() { + case 'a': + ui.ShowApparentSize = !ui.ShowApparentSize + case 'B': + ui.ShowRelativeSize = !ui.ShowRelativeSize + case 'c': + ui.showItemCount = !ui.showItemCount + case 'm': + ui.showMtime = !ui.showMtime + } + if ui.currentDir != nil { + row, column := ui.table.GetSelection() + ui.showDir() + ui.table.Select(row, column) + } +} + +func (ui *UI) handleSorting(key *tcell.EventKey) { + switch key.Rune() { + case 's': + ui.setSorting("size") + case 'C': + ui.setSorting("itemCount") + case 'n': + ui.setSorting("name") + case 'M': + ui.setSorting("mtime") + } +} + +func (ui *UI) handleLeft() { + if ui.currentDirPath == ui.topDirPath { + if ui.devices != nil { + ui.currentDir = nil + err := ui.ListDevices(ui.getter) + if err != nil { + ui.showErr("Error listing devices", err) + } + } else if ui.browseParentDirs { + ui.analyzeParentOfTopDir() + } + return + } + if ui.currentDir != nil { + ui.fileItemSelected(0, 0) + } +} + +func (ui *UI) analyzeParentOfTopDir() { + if ui.currentDir == nil || ui.isInArchive() { + return + } + + currentPath := ui.currentDir.GetPath() + parentPath := filepath.Dir(currentPath) + if parentPath == currentPath { + return + } + + ui.Analyzer.ResetProgress() + ui.linkedItems = make(fs.HardLinkedItems) + + if err := analyzeParentPath(ui, parentPath, nil); err != nil { + ui.showErr("Error analyzing parent directory", err) + } +} + +func (ui *UI) handleRight() { + row, column := ui.table.GetSelection() + if ui.currentDirPath != ui.topDirPath && row == 0 { // do not select /.. + return + } + + if ui.currentDir != nil { + ui.fileItemSelected(row, column) + } else { + ui.deviceItemSelected(row, column) + } +} + +func (ui *UI) handleDelete(shouldEmpty bool) { + if ui.currentDir == nil { + return + } + // do not allow deleting parent dir + row, column := ui.table.GetSelection() + selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) + if !ok || selectedFile == ui.currentDir.GetParent() { + return + } + + if ui.askBeforeDelete { + ui.confirmDeletion(shouldEmpty) + } else { + ui.delete(shouldEmpty) + } +} + +func (ui *UI) handleMark() { + if ui.currentDir == nil { + return + } + // do not allow deleting parent dir + row, column := ui.table.GetSelection() + selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) + if !ok || selectedFile == ui.currentDir.GetParent() { + return + } + + ui.fileItemMarked(row) +} + +func (ui *UI) ignoreItem() { + if ui.currentDir == nil { + return + } + // do not allow ignoring parent dir + row, column := ui.table.GetSelection() + selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) + if !ok || selectedFile == ui.currentDir.GetParent() { + return + } + + if _, ok := ui.ignoredRows[row]; ok { + delete(ui.ignoredRows, row) + } else { + ui.ignoredRows[row] = struct{}{} + } + ui.showDir() + // select next row if possible + ui.table.Select(min(row+1, ui.table.GetRowCount()-1), 0) +} diff --git a/tui/keys_test.go b/tui/keys_test.go new file mode 100644 index 0000000..2465932 --- /dev/null +++ b/tui/keys_test.go @@ -0,0 +1,1315 @@ +package tui + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/dundee/gdu/v5/internal/testanalyze" + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +type devicesInfoGetterErrMock struct{} + +func (m devicesInfoGetterErrMock) GetDevicesInfo() (device.Devices, error) { + return nil, fmt.Errorf("failed getting devices") +} + +func (m devicesInfoGetterErrMock) GetMounts() (device.Devices, error) { + return nil, fmt.Errorf("failed getting mounts") +} + +func TestShowHelp(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) + + assert.True(t, ui.pages.HasPage("help")) +} + +func TestCloseHelp(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.showHelp() + + assert.True(t, ui.pages.HasPage("help")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyEsc, 'q', 0)) + + assert.False(t, ui.pages.HasPage("help")) +} + +func TestCloseHelpWithQuestionMark(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.showHelp() + + assert.True(t, ui.pages.HasPage("help")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) + + assert.False(t, ui.pages.HasPage("help")) +} + +func TestKeyWhileDeleting(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + modal := tview.NewModal().SetText("Deleting...") + ui.pages.AddPage("deleting", modal, true, true) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyEnter, ' ', 0)) + assert.Equal(t, tcell.KeyEnter, key.Key()) +} + +func TestLeftRightKeyWhileConfirm(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + modal := tview.NewModal().SetText("Really?") + ui.pages.AddPage("confirm", modal, true, true) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 0, 0)) + assert.Equal(t, tcell.KeyLeft, key.Key()) + key = ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 0, 0)) + assert.Equal(t, tcell.KeyRight, key.Key()) + key = ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'h', 0)) + assert.Equal(t, tcell.KeyLeft, key.Key()) + key = ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'l', 0)) + assert.Equal(t, tcell.KeyRight, key.Key()) +} + +func TestMoveLeftRight(t *testing.T) { + origWD, err := os.Getwd() + assert.Nil(t, err) + + err = os.Chdir(t.TempDir()) + assert.Nil(t, err) + defer func() { + err := os.Chdir(origWD) + assert.Nil(t, err) + }() + + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.done = make(chan struct{}) + ui.browseParentDirs = true + err = ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + + assert.Equal(t, "nested", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // try /.. first + + assert.Equal(t, "nested", ui.currentDir.GetName()) + + ui.table.Select(1, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + + assert.Equal(t, "subnested", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) + + assert.Equal(t, "nested", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, filepath.Dir("test_dir"), ui.currentDirPath) +} + +func TestMoveRightOnDevice(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + ui.SetIgnoreDirPaths([]string{}) + err := ui.ListDevices(getDevicesInfoMock()) + assert.Nil(t, err) + + ui.table.Select(1, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + // go back to list of devices + ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) + + assert.Nil(t, ui.currentDir) + assert.Equal(t, "/dev/root", ui.table.GetCell(1, 0).GetReference().(*device.Device).Name) +} + +func TestHandleLeftShowsErrorWhenListDevicesFails(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.currentDirPath = "test_dir" + ui.topDirPath = "test_dir" + ui.devices = device.Devices{&device.Device{Name: "x"}} + ui.getter = devicesInfoGetterErrMock{} + + ui.handleLeft() + + assert.True(t, ui.pages.HasPage("error")) +} + +func TestHandleLeftAtTopDirDoesNothingWhenBrowseParentDirsDisabled(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.currentDirPath = "test_dir" + ui.topDirPath = "test_dir" + ui.currentDir = &analyze.Dir{ + File: &analyze.File{Name: "test_dir"}, + BasePath: ".", + } + + ui.handleLeft() + + assert.False(t, ui.pages.HasPage("error")) + assert.Equal(t, "test_dir", ui.currentDirPath) +} + +func TestAnalyzeParentOfTopDirNilCurrentDir(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.currentDir = nil + + ui.analyzeParentOfTopDir() + + assert.False(t, ui.pages.HasPage("error")) +} + +func TestAnalyzeParentOfTopDirInArchive(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.currentDir = &analyze.ZipDir{Dir: &analyze.Dir{}} + + ui.analyzeParentOfTopDir() + + assert.False(t, ui.pages.HasPage("error")) +} + +func TestAnalyzeParentOfTopDirAtFilesystemRoot(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.currentDir = &analyze.Dir{File: &analyze.File{Name: "/"}} + + ui.analyzeParentOfTopDir() + + assert.False(t, ui.pages.HasPage("error")) +} + +func TestAnalyzeParentOfTopDirShowsErrorWhenAnalyzeFails(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.currentDir = &analyze.Dir{ + File: &analyze.File{Name: "test_dir"}, + BasePath: ".", + } + origAnalyzeParentPath := analyzeParentPath + t.Cleanup(func() { + analyzeParentPath = origAnalyzeParentPath + }) + analyzeParentPath = func(ui *UI, path string, parentDir fs.Item) error { + return errors.New("boom") + } + + ui.analyzeParentOfTopDir() + + assert.True(t, ui.pages.HasPage("error")) +} + +func TestStop(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + assert.Nil(t, key) +} + +func TestStopWithPrintingPath(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false) + + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'Q', 0)) + assert.Nil(t, key) + + assert.Equal(t, "test_dir\n", buff.String()) +} + +func TestSpawnShell(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false) + called := false + ui.exec = func(argv0 string, argv, envv []string) error { + called = true + return nil + } + + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) + assert.Nil(t, key) + assert.True(t, called) +} + +func TestSpawnShellWithoutDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false) + called := false + ui.exec = func(argv0 string, argv, envv []string) error { + called = true + return nil + } + + ui.done = make(chan struct{}) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) + assert.Nil(t, key) + assert.False(t, called) +} + +func TestSpawnShellWithWrongDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false) + called := false + ui.exec = func(argv0 string, argv, envv []string) error { + called = true + return nil + } + + ui.done = make(chan struct{}) + ui.currentDir = &analyze.Dir{} + ui.currentDirPath = "/xxxxx" + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) + assert.Nil(t, key) + assert.False(t, called) + assert.True(t, ui.pages.HasPage("error")) +} + +func TestSpawnShellWithError(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false) + called := false + ui.exec = func(argv0 string, argv, envv []string) error { + called = true + return errors.New("wrong shell") + } + + ui.done = make(chan struct{}) + ui.currentDir = &analyze.Dir{} + ui.currentDirPath = "." + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) + assert.Nil(t, key) + assert.True(t, called) + assert.True(t, ui.pages.HasPage("error")) +} + +func TestSpawnShellWithNoSpawnShell(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false) + called := false + ui.exec = func(argv0 string, argv, envv []string) error { + called = true + return nil + } + + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.SetNoSpawnShell() + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) + assert.Nil(t, key) + assert.False(t, called) +} + +func TestOpenItemWithNoSpawnShell(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + buff := &bytes.Buffer{} + ui := CreateUI(app, simScreen, buff, true, true, false, false) + + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.SetNoSpawnShell() + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'o', 0)) + assert.Nil(t, key) +} + +func TestShowConfirm(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(1, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + assert.True(t, ui.pages.HasPage("confirm")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) + + assert.False(t, ui.pages.HasPage("help")) +} + +func TestDeleteEmpty(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + assert.NotNil(t, key) +} + +func TestMarkEmpty(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + assert.NotNil(t, key) +} + +func TestIgnoreEmpty(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) + assert.NotNil(t, key) +} + +func TestDelete(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteWithNoDelete(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.SetNoDelete() + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + assert.DirExists(t, "test_dir/nested") +} + +func TestDeleteMarked(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteParent(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + assert.DirExists(t, "test_dir/nested") +} + +func TestMarkParent(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + assert.Equal(t, len(ui.markedRows), 0) +} + +func TestIgnoreParent(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) + + assert.Equal(t, len(ui.ignoredRows), 0) +} + +func TestEmptyDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") +} + +func TestMarkedEmptyDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") +} + +func TestIgnoreDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // into nested + assert.Equal(t, 3, ui.table.GetRowCount()) + + ui.table.Select(1, 0) // subnested + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) // ignore subnested + + row, _ := ui.table.GetSelection() + assert.Equal(t, 2, row) // selection moves to next row + + ui.table.Select(1, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) // unignore subnested +} + +func TestEmptyFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // into nested + + ui.table.Select(2, 0) // file2 + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.DirExists(t, "test_dir/nested/subnested") +} + +func TestMarkedEmptyFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // into nested + + ui.table.Select(2, 0) // file2 + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.DirExists(t, "test_dir/nested/subnested") +} + +func TestSortByApparentSize(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'a', 0)) + + assert.True(t, ui.ShowApparentSize) +} + +func TestShowFileCount(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'c', 0)) + + assert.True(t, ui.showItemCount) +} + +func TestShowFileCountBW(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'c', 0)) + + assert.True(t, ui.showItemCount) +} + +func TestShowMtime(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'm', 0)) + + assert.True(t, ui.showMtime) +} + +func TestShowMtimeBW(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'm', 0)) + + assert.True(t, ui.showMtime) +} + +func TestShowRelativeBar(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.False(t, ui.ShowRelativeSize) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'B', 0)) + + assert.True(t, ui.ShowRelativeSize) +} + +func TestRescan(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'r', 0)) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, parentDir, ui.currentDir.GetParent()) + + assert.Equal(t, 5, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") +} + +func TestSorting(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(1, 0) + // mark the item for deletion + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + assert.Equal(t, 1, len(ui.markedRows)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 's', 0)) + assert.Equal(t, "size", ui.sortBy) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'C', 0)) + assert.Equal(t, "itemCount", ui.sortBy) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'n', 0)) + assert.Equal(t, "name", ui.sortBy) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'M', 0)) + assert.Equal(t, "mtime", ui.sortBy) + + // marking should be dropped after sorting + assert.Equal(t, 0, len(ui.markedRows)) +} + +func TestShowFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(2, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'v', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) +} + +func TestShowFileWithNoViewFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.SetNoViewFile() + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(2, 0) + previousHeaderText := ui.header.GetText(false) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'v', 0)) + + assert.False(t, ui.pages.HasPage("file")) + assert.Equal(t, " Viewing files is disabled!", ui.header.GetText(false)) + + time.Sleep(2100 * time.Millisecond) + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, previousHeaderText, ui.header.GetText(false)) +} + +func TestShowInfoAndMoveAround(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) + + assert.True(t, ui.pages.HasPage("info")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'k', 0)) // move up + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'j', 0)) // move down + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'k', 0)) // move up + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) // does nothing + + assert.True(t, ui.pages.HasPage("info")) // we can still see info page + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + + assert.False(t, ui.pages.HasPage("info")) +} + +func TestBlockedActionsInArchive(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + // Simulate being in a zip dir + zipDir := &analyze.ZipDir{ + Dir: &analyze.Dir{ + File: &analyze.File{ + Name: "test.zip", + Flag: 'Z', + }, + }, + } + ui.currentDir = zipDir + + // Test 'd' (delete) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + assert.True(t, ui.pages.HasPage("error")) + ui.pages.RemovePage("error") + + // Test 'e' (empty) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + assert.True(t, ui.pages.HasPage("error")) + ui.pages.RemovePage("error") + + // Test 'v' (view) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'v', 0)) + assert.True(t, ui.pages.HasPage("error")) + ui.pages.RemovePage("error") + + // Test 'b' (shell) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) + assert.True(t, ui.pages.HasPage("error")) + ui.pages.RemovePage("error") +} diff --git a/tui/marked.go b/tui/marked.go new file mode 100644 index 0000000..d82316f --- /dev/null +++ b/tui/marked.go @@ -0,0 +1,148 @@ +package tui + +import ( + "strconv" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) fileItemMarked(row int) { + if _, ok := ui.markedRows[row]; ok { + delete(ui.markedRows, row) + } else { + ui.markedRows[row] = struct{}{} + } + ui.showDir() + // select next row if possible + ui.table.Select(min(row+1, ui.table.GetRowCount()-1), 0) +} + +func (ui *UI) deleteMarked(shouldEmpty bool) { + var action, acting string + if shouldEmpty { + action = actionEmpty + acting = actingEmpty + } else { + action = actionDelete + acting = actingDelete + } + + var currentDir fs.Item + var markedItems []fs.Item + for row := range ui.markedRows { + item := ui.table.GetCell(row, 0).GetReference().(fs.Item) + markedItems = append(markedItems, item) + } + + if ui.deleteInBackground { + ui.queueForDeletion(markedItems, shouldEmpty) + return + } + + modal := tview.NewModal() + ui.pages.AddPage(acting, modal, true, true) + + currentRow, _ := ui.table.GetSelection() + + var deleteFun func(fs.Item, fs.Item) error + + go func() { + for _, one := range markedItems { + ui.app.QueueUpdateDraw(func() { + modal.SetText( + cases.Title(language.English).String(acting) + + " " + + tview.Escape(one.GetName()) + + "...", + ) + }) + + if shouldEmpty && !one.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + + var deleteItems []fs.Item + if shouldEmpty && one.IsDir() { + currentDir = one + for file := range currentDir.GetFiles(fs.SortBySize, fs.SortDesc) { + deleteItems = append(deleteItems, file) + } + } else { + currentDir = ui.currentDir + deleteItems = append(deleteItems, one) + } + + for _, item := range deleteItems { + if err := deleteFun(currentDir, item); err != nil { + msg := "Can't " + action + " " + tview.Escape(one.GetName()) + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.showErr(msg, err) + }) + if ui.done != nil { + ui.done <- struct{}{} + } + return + } + } + } + + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.pages.RemovePage(acting) + ui.markedRows = make(map[int]struct{}) + x, y := ui.table.GetOffset() + ui.showDir() + ui.table.Select(min(currentRow, ui.table.GetRowCount()-1), 0) + ui.table.SetOffset(min(x, ui.table.GetRowCount()-1), y) + }) + + if ui.done != nil { + ui.done <- struct{}{} + } + }() +} + +func (ui *UI) confirmDeletionMarked(shouldEmpty bool) { + var action string + if shouldEmpty { + action = actionEmpty + } else { + action = actionDelete + } + + modal := tview.NewModal(). + SetText( + "Are you sure you want to " + + action + " [::b]" + + strconv.Itoa(len(ui.markedRows)) + + "[::-] items?", + ). + AddButtons([]string{"no", "yes", "don't ask me again"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonIndex { + case 2: + ui.askBeforeDelete = false + fallthrough + case 1: + ui.deleteMarked(shouldEmpty) + } + ui.pages.RemovePage("confirm") + }) + + if !ui.UseColors { + modal.SetBackgroundColor(tcell.ColorGray) + } else { + modal.SetBackgroundColor(tcell.ColorBlack) + } + modal.SetBorderColor(tcell.ColorDefault) + + ui.pages.AddPage("confirm", modal, true, true) +} diff --git a/tui/marked_test.go b/tui/marked_test.go new file mode 100644 index 0000000..41d5e16 --- /dev/null +++ b/tui/marked_test.go @@ -0,0 +1,22 @@ +package tui + +import ( + "testing" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/stretchr/testify/assert" +) + +func TestItemMarked(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + + ui.fileItemMarked(1) + assert.Equal(t, ui.markedRows, map[int]struct{}{1: {}}) + + ui.fileItemMarked(1) + assert.Equal(t, ui.markedRows, map[int]struct{}{}) +} diff --git a/tui/mouse.go b/tui/mouse.go new file mode 100644 index 0000000..204f49b --- /dev/null +++ b/tui/mouse.go @@ -0,0 +1,66 @@ +package tui + +import ( + "time" + + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) onMouse(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction) { + if event == nil { + return nil, action + } + + if ui.pages.HasPage("confirm") || + ui.pages.HasPage("progress") || + ui.pages.HasPage("deleting") || + ui.pages.HasPage("emptying") || + ui.pages.HasPage("help") { + return nil, action + } + + // nolint: exhaustive // Why: we don't need to handle all mouse events + switch action { + case tview.MouseLeftDoubleClick: + row, column := ui.table.GetSelection() + if ui.currentDirPath != ui.topDirPath && row == 0 { + ui.handleLeft() + } else { + selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) + if selectedFile.IsDir() { + ui.handleRight() + } else { + if ui.noViewFile { + previousHeaderText := ui.header.GetText(false) + + ui.header.SetText(" Viewing files is disabled!") + + go func() { + time.Sleep(2 * time.Second) + ui.app.QueueUpdateDraw(func() { + ui.header.Clear() + ui.header.SetText(previousHeaderText) + }) + }() + + return nil, action + } + ui.showFile() + } + } + return nil, action + case tview.MouseScrollUp, tview.MouseScrollDown: + row, column := ui.table.GetSelection() + if action == tview.MouseScrollUp && row > 0 { + row-- + } else if action == tview.MouseScrollDown && row+1 < ui.table.GetRowCount() { + row++ + } + ui.table.Select(row, column) + return nil, action + } + + return event, action +} diff --git a/tui/mouse_test.go b/tui/mouse_test.go new file mode 100644 index 0000000..aaf121f --- /dev/null +++ b/tui/mouse_test.go @@ -0,0 +1,162 @@ +package tui + +import ( + "bytes" + "testing" + "time" + + "github.com/dundee/gdu/v5/internal/testanalyze" + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +func TestDoubleClick(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.table.Select(0, 0) + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) + assert.Equal(t, "nested", ui.currentDir.GetName()) + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + // show file content + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(2, 0) + selectedFile := ui.table.GetCell(2, 0).GetReference().(fs.Item) + assert.Equal(t, selectedFile.GetName(), "file2") + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) + assert.True(t, ui.pages.HasPage("file")) +} + +func TestDoubleClickNoViewFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.table.Select(2, 0) + ui.SetNoViewFile() + previousHeaderText := ui.header.GetText(false) + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) + assert.False(t, ui.pages.HasPage("file")) + assert.Equal(t, " Viewing files is disabled!", ui.header.GetText(false)) + + time.Sleep(2100 * time.Millisecond) + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, previousHeaderText, ui.header.GetText(false)) +} + +func TestScroll(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseScrollDown) + row, _ := ui.table.GetSelection() + assert.Equal(t, row, 1) + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseScrollUp) + row, _ = ui.table.GetSelection() + assert.Equal(t, row, 0) +} + +func TestScrollWhenPageOpened(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + // open confirm dialog + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseScrollDown) + row, _ := ui.table.GetSelection() + // scrolling does nothing + assert.Equal(t, 0, row) +} + +func TestEmptyEvent(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + event, action := ui.onMouse(nil, tview.MouseMove) + assert.True(t, event == nil) + assert.Equal(t, action, tview.MouseMove) +} + +func TestMouseMove(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + event, action := ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseMove) + assert.True(t, event != nil) + assert.Equal(t, action, tview.MouseMove) +} diff --git a/tui/progress.go b/tui/progress.go new file mode 100644 index 0000000..eb0c846 --- /dev/null +++ b/tui/progress.go @@ -0,0 +1,53 @@ +package tui + +import ( + "time" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/path" +) + +func (ui *UI) updateProgress() { + color := "[white:black:b]" + if ui.UseColors { + color = "[red:black:b]" + } + + progressChan := ui.Analyzer.GetProgressChan() + doneChan := ui.Analyzer.GetDone() + + var progress common.CurrentProgress + start := time.Now() + + for { + select { + case progress = <-progressChan: + case <-doneChan: + ui.app.QueueUpdateDraw(func() { + ui.progress.SetTitle(" Finalizing... ") + ui.progress.SetText("Calculating disk usage...") + }) + return + } + + func(itemCount int64, totalSize int64, currentItem string) { + delta := time.Since(start).Round(time.Second) + + ui.app.QueueUpdateDraw(func() { + ui.progress.SetText("Total items: " + + color + + common.FormatNumber(int64(itemCount)) + + "[white:black:-], size: " + + color + + ui.formatSize(totalSize, false, false) + + "[white:black:-], elapsed time: " + + color + + delta.String() + + "[white:black:-]\nCurrent item: [white:black:b]" + + path.ShortenPath(currentItem, ui.currentItemNameMaxLen)) + }) + }(progress.ItemCount, progress.TotalSize, progress.CurrentItemName) + + time.Sleep(100 * time.Millisecond) + } +} diff --git a/tui/show.go b/tui/show.go new file mode 100644 index 0000000..7b1ff0c --- /dev/null +++ b/tui/show.go @@ -0,0 +1,407 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/build" + "github.com/dundee/gdu/v5/pkg/fs" +) + +var ( + helpDisabledSuffix = " (disabled)" + + helpText = ` [::b]up/down, k/j [white:black:-]Move cursor up/down + [::b]pgup/pgdn, g/G [white:black:-]Move cursor top/bottom + [::b]enter, right, l [white:black:-]Go to directory/device + [::b]left, h [white:black:-]Go to parent directory + + [::b]r [white:black:-]Rescan current directory + [::b]E [white:black:-]Export analysis data to file as JSON + [::b]/ [white:black:-]Search items by name + [::b]T [white:black:-]Filter items by file type (extension) + [::b]a [white:black:-]Toggle between showing disk usage and apparent size + [::b]B [white:black:-]Toggle bar alignment to biggest file or directory + [::b]c [white:black:-]Show/hide file count + [::b]m [white:black:-]Show/hide latest mtime + [::b]b [white:black:-]Spawn shell in current directory + [::b]q [white:black:-]Quit gdu + [::b]Q [white:black:-]Quit gdu and print current directory path + +Item under cursor: + [::b]d [white:black:-]Delete file or directory + [::b]e [white:black:-]Empty file or directory + [::b]space [white:black:-]Mark file or directory for deletion + [::b]I [white:black:-]Ignore file or directory + [::b]v [white:black:-]Show content of file + [::b]o [white:black:-]Open file or directory in external program + [::b]i [white:black:-]Show info about item + +Sort by (twice toggles asc/desc): + [::b]n [white:black:-]Sort by name (asc/desc) + [::b]s [white:black:-]Sort by size (asc/desc) + [::b]C [white:black:-]Sort by file count (asc/desc) + [::b]M [white:black:-]Sort by mtime (asc/desc)` +) + +// nolint: funlen // Why: complex function +func (ui *UI) showDir() { + var ( + totalUsage int64 + totalSize int64 + maxUsage int64 + maxSize int64 + itemCount int64 + ) + + ui.currentDirPath = ui.currentDir.GetPath() + + if ui.changeCwdFn != nil { + err := ui.changeCwdFn(ui.currentDirPath) + if err != nil { + log.Printf("error setting cwd: %s", err.Error()) + } + log.Printf("changing cwd to %s", ui.currentDirPath) + } + + ui.currentDirLabel.SetText("[::b] --- " + + tview.Escape( + strings.TrimPrefix(ui.currentDirPath, build.RootPathPrefix), + ) + + " ---").SetDynamicColors(true) + + ui.table.Clear() + + rowIndex := 0 + if ui.currentDirPath != ui.topDirPath { + prefix := " " + if len(ui.markedRows) > 0 { + prefix += " " + } + + cell := tview.NewTableCell(prefix + "[::b]/..") + + // Use the collapsed parent logic to handle navigation back through collapsed paths + var collapsedParent fs.Item + if ui.collapsePath { + collapsedParent = findCollapsedParent(ui.currentDir) + } else { + collapsedParent = ui.currentDir.GetParent() + } + cell.SetReference(collapsedParent) + cell.SetStyle(tcell.Style{}.Foreground(tcell.ColorDefault)) + ui.table.SetCell(0, 0, cell) + rowIndex++ + } + + sortBy, sortOrder := ui.getSortParams() + + unlock := ui.currentDir.RLock() + defer unlock() + + i := rowIndex + maxUsage = 0 + maxSize = 0 + for item := range ui.currentDir.GetFiles(sortBy, sortOrder) { + if _, ignored := ui.ignoredRows[i]; ignored { + i++ + continue + } + + if ui.ShowRelativeSize { + if item.GetUsage() > maxUsage { + maxUsage = item.GetUsage() + } + if item.GetSize() > maxSize { + maxSize = item.GetSize() + } + } else { + maxSize += item.GetSize() + maxUsage += item.GetUsage() + } + i++ + } + + for item := range ui.currentDir.GetFiles(sortBy, sortOrder) { + if ui.filterValue != "" && !strings.Contains( + strings.ToLower(item.GetName()), + strings.ToLower(ui.filterValue), + ) { + continue + } + + if !ui.matchesTypeFilter(item.GetName(), item.IsDir()) { + continue + } + + _, ignored := ui.ignoredRows[rowIndex] + + if !ignored { + totalUsage += item.GetUsage() + totalSize += item.GetSize() + itemCount += item.GetItemCount() + } + + _, marked := ui.markedRows[rowIndex] + + var cell *tview.TableCell + var reference fs.Item + + // Check if this directory can be collapsed + if item.IsDir() { + var collapsedPath *CollapsedPath + if ui.collapsePath { + collapsedPath = findCollapsiblePath(item) + } + + if collapsedPath != nil { + // Format as collapsed path + cell = tview.NewTableCell(ui.formatCollapsedRow(collapsedPath, maxUsage, maxSize, marked, ignored)) + // Reference should point to the deepest directory for navigation + reference = collapsedPath.DeepestDir + } else { + // Regular directory formatting + cell = tview.NewTableCell(ui.formatFileRow(item, maxUsage, maxSize, marked, ignored)) + reference = item + } + } else { + // Regular file formatting + cell = tview.NewTableCell(ui.formatFileRow(item, maxUsage, maxSize, marked, ignored)) + reference = item + } + + cell.SetReference(reference) + + switch { + case ignored: + cell.SetStyle(tcell.Style{}.Foreground(tview.Styles.SecondaryTextColor)) + case marked: + cell.SetStyle(tcell.Style{}.Foreground(tview.Styles.PrimaryTextColor)) + cell.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) + default: + cell.SetStyle(tcell.Style{}.Foreground(tcell.ColorDefault)) + } + + ui.table.SetCell(rowIndex, 0, cell) + rowIndex++ + } + + var footerNumberColor, footerTextColor string + if ui.UseColors { + footerNumberColor = fmt.Sprintf( + "[%s:%s:b]", + ui.footerNumberColor, + ui.footerBackgroundColor, + ) + footerTextColor = fmt.Sprintf( + "[%s:%s:-]", + ui.footerTextColor, + ui.footerBackgroundColor, + ) + } else { + footerNumberColor = "[black:white:b]" + footerTextColor = blackOnWhite + } + + selected := "" + if len(ui.markedRows) > 0 { + selected = " Selected items: " + footerNumberColor + + strconv.Itoa(len(ui.markedRows)) + footerTextColor + } + + timeFilterText := ui.formatTimeFilterInfo() + + typeFilterText := ui.formatTypeFilterInfo(footerNumberColor, footerTextColor) + + ui.footerLabel.SetText( + selected + footerTextColor + + " Total disk usage: " + + footerNumberColor + + ui.formatSize(totalUsage, true, false) + + " Apparent size: " + + footerNumberColor + + ui.formatSize(totalSize, true, false) + + " Items: " + footerNumberColor + fmt.Sprintf("%d", itemCount) + + footerTextColor + + " Sorting by: " + ui.sortBy + " " + ui.sortOrder + + typeFilterText + + timeFilterText) + + ui.table.Select(0, 0) + ui.table.ScrollToBeginning() + + if !ui.filtering && !ui.typeFiltering { + ui.app.SetFocus(ui.table) + } +} + +func (ui *UI) showDevices() { + var totalUsage int64 + + ui.table.Clear() + ui.table.SetCell(0, 0, tview.NewTableCell("Device name").SetSelectable(false)) + ui.table.SetCell(0, 1, tview.NewTableCell("Size").SetSelectable(false)) + ui.table.SetCell(0, 2, tview.NewTableCell("Used").SetSelectable(false)) + ui.table.SetCell(0, 3, tview.NewTableCell("Used part").SetSelectable(false)) + ui.table.SetCell(0, 4, tview.NewTableCell("Free").SetSelectable(false)) + ui.table.SetCell(0, 5, tview.NewTableCell("Mount point").SetSelectable(false)) + + var textColor, sizeColor string + if ui.UseColors { + textColor = "[#3498db:-:b]" + sizeColor = "[#edb20a:-:b]" + } else { + textColor = "[white:-:b]" + sizeColor = "[white:-:b]" + } + + ui.sortDevices() + + for i, device := range ui.devices { + totalUsage += device.GetUsage() + ui.table.SetCell(i+1, 0, tview.NewTableCell(textColor+device.Name).SetReference(ui.devices[i])) + ui.table.SetCell(i+1, 1, tview.NewTableCell(ui.formatSize(device.Size, false, true))) + ui.table.SetCell(i+1, 2, tview.NewTableCell(sizeColor+ui.formatSize(device.Size-device.Free, false, true))) + ui.table.SetCell(i+1, 3, tview.NewTableCell(getDeviceUsagePart(device, ui.useOldSizeBar))) + ui.table.SetCell(i+1, 4, tview.NewTableCell(ui.formatSize(device.Free, false, true))) + ui.table.SetCell(i+1, 5, tview.NewTableCell(textColor+device.MountPoint).SetReference(ui.devices[i])) + } + + var footerNumberColor, footerTextColor string + if ui.UseColors { + footerNumberColor = fmt.Sprintf( + "[%s:%s:b]", + ui.footerNumberColor, + ui.footerBackgroundColor, + ) + footerTextColor = fmt.Sprintf( + "[%s:%s:-]", + ui.footerTextColor, + ui.footerBackgroundColor, + ) + } else { + footerNumberColor = "[black:white:b]" + footerTextColor = blackOnWhite + } + + ui.footerLabel.SetText( + " Total usage: " + + footerNumberColor + + ui.formatSize(totalUsage, true, false) + + footerTextColor + + " Sorting by: " + ui.sortBy + " " + ui.sortOrder) + + ui.table.Select(1, 0) + ui.table.SetSelectedFunc(ui.deviceItemSelected) + + if ui.topDirPath != "" { + for i, device := range ui.devices { + if device.MountPoint == ui.topDirPath { + ui.table.Select(i+1, 0) + break + } + } + } +} + +func (ui *UI) showErr(msg string, err error) { + text := msg + if err != nil { + text += ": " + err.Error() + } + + modal := tview.NewModal(). + SetText(text). + AddButtons([]string{"ok"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + ui.pages.RemovePage("error") + }) + + if !ui.UseColors { + modal.SetBackgroundColor(tcell.ColorGray) + } + + ui.pages.AddPage("error", modal, true, true) + ui.app.SetFocus(modal) +} + +func (ui *UI) showErrFromGo(msg string, err error) { + ui.app.QueueUpdateDraw(func() { + ui.showErr(msg, err) + }) +} + +func (ui *UI) showHelp() { + text := tview.NewTextView().SetDynamicColors(true) + text.SetBorder(true).SetBorderPadding(2, 2, 2, 2) + text.SetBorderColor(tcell.ColorDefault) + text.SetTitle(" gdu help ") + text.SetScrollable(true) + + formattedHelpText := ui.formatHelpTextFor() + text.SetText(formattedHelpText) + + maxHeight := strings.Count(formattedHelpText, "\n") + 7 + _, height := ui.screen.Size() + if height > maxHeight { + height = maxHeight + } + + flex := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(text, height, 1, false). + AddItem(nil, 0, 1, false), 80, 1, false). + AddItem(nil, 0, 1, false) + + ui.help = flex + ui.pages.AddPage("help", flex, true, true) + ui.app.SetFocus(text) +} + +func (ui *UI) formatHelpTextFor() string { + lines := strings.Split(helpText, "\n") + + for i, line := range lines { + if ui.UseColors { + lines[i] = strings.ReplaceAll( + strings.ReplaceAll(line, defaultColorBold, "[red]"), + whiteOnBlack, + "[white]", + ) + } + + isFound := (strings.Contains(line, "Empty file or directory") || + strings.Contains(line, "Delete file or directory")) + + if ui.noDelete && isFound { + lines[i] += helpDisabledSuffix + } else if ui.noDeleteWithFilter && isFound { + lines[i] += " (disabled/filter)" + } + + if ui.noSpawnShell && (strings.Contains(line, "Spawn shell in current directory") || + strings.Contains(line, "Open file or directory in external program")) { + lines[i] += helpDisabledSuffix + } + + if ui.noViewFile && strings.Contains(line, "Show content of file") { + lines[i] += helpDisabledSuffix + } + } + + return strings.Join(lines, "\n") +} + +func (ui *UI) formatTypeFilterInfo(numberColor, textColor string) string { + if ui.typeFilterValue == "" { + return "" + } + return " Type filter: " + numberColor + ui.typeFilterValue + textColor +} diff --git a/tui/show_file.go b/tui/show_file.go new file mode 100644 index 0000000..08f82e3 --- /dev/null +++ b/tui/show_file.go @@ -0,0 +1,151 @@ +package tui + +import ( + "bufio" + "compress/bzip2" + "compress/gzip" + "io" + "os" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/h2non/filetype" + "github.com/h2non/filetype/matchers" + "github.com/pkg/errors" + "github.com/rivo/tview" + "github.com/ulikunitz/xz" + + "github.com/dundee/gdu/v5/build" + "github.com/dundee/gdu/v5/pkg/fs" +) + +func (ui *UI) showFile() *tview.TextView { + if ui.currentDir == nil { + return nil + } + + row, column := ui.table.GetSelection() + cell := ui.table.GetCell(row, column) + if cell == nil || cell.GetReference() == nil { + return nil + } + + selectedFile, ok := cell.GetReference().(fs.Item) + if !ok || selectedFile == nil || selectedFile.IsDir() { + return nil + } + + path := selectedFile.GetPath() + f, err := os.Open(path) + if err != nil { + ui.showErr("Error opening file", err) + return nil + } + scanner, err := getScanner(f) + if err != nil { + ui.showErr("Error reading file", err) + return nil + } + + totalLines := 0 + + file := tview.NewTextView() + ui.currentDirLabel.SetText("[::b] --- " + + strings.TrimPrefix(path, build.RootPathPrefix) + + " ---").SetDynamicColors(true) + + readNextPart := func(linesCount int) int { + var err error + readLines := 0 + for scanner.Scan() && readLines <= linesCount { + _, err = file.Write(scanner.Bytes()) + if err != nil { + ui.showErr("Error reading file", err) + return 0 + } + _, err = file.Write([]byte("\n")) + if err != nil { + ui.showErr("Error reading file", err) + return 0 + } + readLines++ + } + return readLines + } + totalLines += readNextPart(defaultLinesCount) + + file.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Rune() == 'q' || event.Key() == tcell.KeyESC { + err = f.Close() + if err != nil { + ui.showErr("Error closing file", err) + return event + } + ui.currentDirLabel.SetText("[::b] --- " + + strings.TrimPrefix(ui.currentDirPath, build.RootPathPrefix) + + " ---").SetDynamicColors(true) + ui.pages.RemovePage("file") + ui.app.SetFocus(ui.table) + return event + } + + if event.Rune() == 'j' || event.Rune() == 'G' || + event.Key() == tcell.KeyDown || event.Key() == tcell.KeyPgDn { + _, _, _, height := file.GetInnerRect() + row, _ := file.GetScrollOffset() + if height+row > totalLines-linesThreshold { + totalLines += readNextPart(defaultLinesCount) + } + } + return event + }) + + grid := tview.NewGrid().SetRows(1, 1, 0, 1).SetColumns(0) + grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). + AddItem(file, 2, 0, 1, 1, 0, 0, true). + AddItem(ui.footerLabel, 3, 0, 1, 1, 0, 0, false) + + ui.pages.HidePage("background") + ui.pages.AddPage("file", grid, true, true) + + return file +} + +func getScanner(f io.ReadSeeker) (scanner *bufio.Scanner, err error) { + // We only have to pass the file header = first 261 bytes + head := make([]byte, 261) + if _, err = f.Read(head); err != nil { + return nil, errors.Wrap(err, "error reading file header") + } + + if pos, err := f.Seek(0, 0); pos != 0 || err != nil { + return nil, errors.Wrap(err, "error seeking file") + } + scanner = bufio.NewScanner(f) + + typ, err := filetype.Match(head) + if err != nil { + return nil, errors.Wrap(err, "error matching file type") + } + + switch typ.MIME.Value { + case matchers.TypeGz.MIME.Value: + r, err := gzip.NewReader(f) + if err != nil { + return nil, errors.Wrap(err, "error creating gzip reader") + } + scanner = bufio.NewScanner(r) + case matchers.TypeBz2.MIME.Value: + r := bzip2.NewReader(f) + scanner = bufio.NewScanner(r) + case matchers.TypeXz.MIME.Value: + r, err := xz.NewReader(f) + if err != nil { + return nil, errors.Wrap(err, "error creating xz reader") + } + scanner = bufio.NewScanner(r) + } + + return scanner, nil +} diff --git a/tui/show_file_test.go b/tui/show_file_test.go new file mode 100644 index 0000000..6cc87eb --- /dev/null +++ b/tui/show_file_test.go @@ -0,0 +1,89 @@ +package tui + +import ( + "bytes" + "compress/gzip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/ulikunitz/xz" +) + +func TestGetScannerForEmptyString(t *testing.T) { + r := bytes.NewReader([]byte{}) + _, err := getScanner(r) + assert.ErrorContains(t, err, "EOF") +} + +func TestGetScannerForPlainString(t *testing.T) { + r := bytes.NewReader([]byte("hello")) + s, err := getScanner(r) + assert.Nil(t, err) + + assert.Equal(t, true, s.Scan()) + assert.Equal(t, "hello", s.Text()) + assert.Equal(t, nil, s.Err()) +} + +func TestGetScannerForGzipped(t *testing.T) { + b := bytes.NewBuffer([]byte{}) + w := gzip.NewWriter(b) + + _, err := w.Write([]byte("hello world")) + assert.Nil(t, err) + + err = w.Close() + assert.Nil(t, err) + + r := bytes.NewReader(b.Bytes()) + s, err := getScanner(r) + assert.Nil(t, err) + + assert.Equal(t, true, s.Scan()) + assert.Equal(t, "hello world", s.Text()) + assert.Equal(t, nil, s.Err()) +} + +func TestGetScannerForBzipped(t *testing.T) { + r := bytes.NewReader([]byte{ + // bzip2 header + 0x42, 0x5A, 0x68, 0x39, + // bzip2 compressed data: "hello" + 0x31, 0x41, 0x59, 0x26, + 0x53, 0x59, 0xC1, 0xC0, + 0x80, 0xE2, 0x00, 0x00, + 0x01, 0x41, 0x00, 0x00, + 0x10, 0x02, 0x44, 0xA0, + 0x00, 0x30, 0xCD, 0x00, + 0xC3, 0x46, 0x29, 0x97, + 0x17, 0x72, 0x45, 0x38, + 0x50, 0x90, 0xC1, 0xC0, + 0x80, 0xE2, + }) + s, err := getScanner(r) + assert.Nil(t, err) + + assert.Equal(t, true, s.Scan()) + assert.Equal(t, "hello", s.Text()) + assert.Equal(t, nil, s.Err()) +} + +func TestGetScannerForXzipped(t *testing.T) { + b := bytes.NewBuffer([]byte{}) + w, err := xz.NewWriter(b) + assert.Nil(t, err) + + _, err = w.Write([]byte("hello world")) + assert.Nil(t, err) + + err = w.Close() + assert.Nil(t, err) + + r := bytes.NewReader(b.Bytes()) + s, err := getScanner(r) + assert.Nil(t, err) + + assert.Equal(t, true, s.Scan()) + assert.Equal(t, "hello world", s.Text()) + assert.Equal(t, nil, s.Err()) +} diff --git a/tui/show_test.go b/tui/show_test.go new file mode 100644 index 0000000..e7bf317 --- /dev/null +++ b/tui/show_test.go @@ -0,0 +1,82 @@ +package tui + +import ( + "bytes" + "strings" + "testing" + + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestHelpNoSpawnShell(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.SetNoDelete() + ui.SetNoSpawnShell() + ui.SetNoViewFile() + ui.showHelp() + + assert.True(t, ui.pages.HasPage("help")) + + helpText := ui.formatHelpTextFor() + + assert.True(t, strings.Contains(helpText, "Delete file or directory (disabled)")) + assert.True(t, strings.Contains(helpText, "Empty file or directory (disabled)")) + assert.True(t, strings.Contains(helpText, "Spawn shell in current directory (disabled)")) + assert.True(t, strings.Contains(helpText, "Open file or directory in external program (disabled)")) + assert.True(t, strings.Contains(helpText, "Show content of file (disabled)")) +} + +func TestCollapsePathFlag(t *testing.T) { + app := testapp.CreateMockedApp(true) + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + + // Create a collapsible structure + deepestDir := &analyze.Dir{ + File: &analyze.File{ + Name: "deepest", + Usage: 100, + Size: 100, + }, + Files: []fs.Item{}, + } + middleDir := &analyze.Dir{ + File: &analyze.File{ + Name: "middle", + Usage: 100, + Size: 100, + }, + Files: []fs.Item{deepestDir}, + } + topDir := &analyze.Dir{ + File: &analyze.File{ + Name: "top", + }, + Files: []fs.Item{middleDir}, + } + deepestDir.SetParent(middleDir) + middleDir.SetParent(topDir) + + ui.currentDir = topDir + ui.topDir = topDir + ui.topDirPath = "top" + + // Default (flag false) -> Should NOT collapse + ui.showDir() + cell := ui.table.GetCell(0, 0) + assert.Contains(t, cell.Text, "middle") + assert.NotContains(t, cell.Text, "deepest") + + // Enable flag -> Should collapse + ui.SetCollapsePath(true) + ui.showDir() + cell = ui.table.GetCell(0, 0) + assert.Contains(t, cell.Text, "middle/deepest") +} diff --git a/tui/sort.go b/tui/sort.go new file mode 100644 index 0000000..4a5cef6 --- /dev/null +++ b/tui/sort.go @@ -0,0 +1,94 @@ +package tui + +import ( + "sort" + + "github.com/dundee/gdu/v5/pkg/device" + "github.com/dundee/gdu/v5/pkg/fs" +) + +const ( + nameSortKey = "name" + sizeSortKey = "size" + itemCountSortKey = "itemCount" + mtimeSortKey = "mtime" + + ascOrder = "asc" + descOrder = "desc" +) + +// SetDefaultSorting sets the default sorting +func (ui *UI) SetDefaultSorting(by, order string) { + if by != "" { + ui.defaultSortBy = by + } + if order == ascOrder || order == descOrder { + ui.defaultSortOrder = order + } +} + +func (ui *UI) setSorting(newOrder string) { + ui.markedRows = make(map[int]struct{}) + + if newOrder == ui.sortBy { + if ui.sortOrder == ascOrder { + ui.sortOrder = descOrder + } else { + ui.sortOrder = ascOrder + } + } else { + ui.sortBy = newOrder + ui.sortOrder = ascOrder + } + + if ui.currentDir != nil { + ui.showDir() + } else if ui.devices != nil && (newOrder == sizeSortKey || newOrder == nameSortKey) { + ui.showDevices() + } +} + +// getSortParams returns the current sort parameters as fs.SortBy and fs.SortOrder +func (ui *UI) getSortParams() (fs.SortBy, fs.SortOrder) { + var sortBy fs.SortBy + switch ui.sortBy { + case nameSortKey: + sortBy = fs.SortByName + case itemCountSortKey: + sortBy = fs.SortByItemCount + case mtimeSortKey: + sortBy = fs.SortByMtime + case sizeSortKey: + if ui.ShowApparentSize { + sortBy = fs.SortByApparentSize + } else { + sortBy = fs.SortBySize + } + default: + sortBy = fs.SortBySize + } + + sortOrder := fs.SortAsc + if ui.sortOrder == descOrder { + sortOrder = fs.SortDesc + } + + return sortBy, sortOrder +} + +func (ui *UI) sortDevices() { + if ui.sortBy == sizeSortKey { + if ui.sortOrder == descOrder { + sort.Sort(sort.Reverse(device.ByUsedSize(ui.devices))) + } else { + sort.Sort(device.ByUsedSize(ui.devices)) + } + } + if ui.sortBy == nameSortKey { + if ui.sortOrder == descOrder { + sort.Sort(sort.Reverse(device.ByName(ui.devices))) + } else { + sort.Sort(device.ByName(ui.devices)) + } + } +} diff --git a/tui/sort_test.go b/tui/sort_test.go new file mode 100644 index 0000000..32c0053 --- /dev/null +++ b/tui/sort_test.go @@ -0,0 +1,207 @@ +package tui + +import ( + "bytes" + "testing" + + "github.com/dundee/gdu/v5/internal/testanalyze" + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/stretchr/testify/assert" +) + +func TestAnalyzeByApparentSize(t *testing.T) { + ui := getAnalyzedPathWithSorting("size", "desc", true) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") +} + +func TestSortByApparentSizeAsc(t *testing.T) { + ui := getAnalyzedPathWithSorting("size", "asc", true) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ccc") +} + +func TestAnalyzeBySize(t *testing.T) { + ui := getAnalyzedPathWithSorting("size", "desc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") +} + +func TestSortBySizeAsc(t *testing.T) { + ui := getAnalyzedPathWithSorting("size", "asc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ccc") +} + +func TestAnalyzeByName(t *testing.T) { + ui := getAnalyzedPathWithSorting("name", "desc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "aaa") +} + +func TestAnalyzeByNameAsc(t *testing.T) { + ui := getAnalyzedPathWithSorting("name", "asc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") +} + +func TestAnalyzeByItemCount(t *testing.T) { + ui := getAnalyzedPathWithSorting("itemCount", "desc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "aaa") +} + +func TestAnalyzeByItemCountAsc(t *testing.T) { + ui := getAnalyzedPathWithSorting("itemCount", "asc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") +} + +func TestAnalyzeByMtime(t *testing.T) { + ui := getAnalyzedPathWithSorting("mtime", "desc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") +} + +func TestAnalyzeByMtimeAsc(t *testing.T) { + ui := getAnalyzedPathWithSorting("mtime", "asc", false) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") + assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") + assert.Contains(t, ui.table.GetCell(3, 0).Text, "aaa") +} + +func TestSetSorting(t *testing.T) { + ui := getAnalyzedPathWithSorting("itemCount", "asc", false) + + ui.setSorting("name") + assert.Equal(t, "name", ui.sortBy) + assert.Equal(t, "asc", ui.sortOrder) + ui.setSorting("name") + assert.Equal(t, "name", ui.sortBy) + assert.Equal(t, "desc", ui.sortOrder) + ui.setSorting("name") + assert.Equal(t, "name", ui.sortBy) + assert.Equal(t, "asc", ui.sortOrder) +} + +func TestSetDEfaultSorting(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + var opts []Option + opts = append(opts, func(ui *UI) { + ui.SetDefaultSorting("name", "asc") + }) + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, opts...) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + + if err := ui.AnalyzePath("test_dir", nil); err != nil { + panic(err) + } + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "name", ui.sortBy) + assert.Equal(t, "asc", ui.sortOrder) +} + +func TestSortDevicesByName(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + + ui.setSorting("name") // sort by name asc + assert.Equal(t, "/dev/boot", ui.devices[0].Name) + + ui.setSorting("name") // sort by name desc + assert.Equal(t, "/dev/root", ui.devices[0].Name) +} + +func TestSortDevicesByUsedSize(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) + err := ui.ListDevices(getDevicesInfoMock()) + + assert.Nil(t, err) + + ui.setSorting("size") // sort by used size asc + assert.Equal(t, "/dev/boot", ui.devices[0].Name) + + ui.setSorting("size") // sort by used size desc + assert.Equal(t, "/dev/root", ui.devices[0].Name) +} + +func getAnalyzedPathWithSorting(sortBy string, sortOrder string, apparentSize bool) *UI { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, apparentSize, false, false) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.done = make(chan struct{}) + ui.sortBy = sortBy + ui.sortOrder = sortOrder + if err := ui.AnalyzePath("test_dir", nil); err != nil { + panic(err) + } + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + return ui +} diff --git a/tui/status.go b/tui/status.go new file mode 100644 index 0000000..ff4c062 --- /dev/null +++ b/tui/status.go @@ -0,0 +1,84 @@ +package tui + +import ( + "fmt" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) toggleStatusBar(show bool) { + var textColor, textBgColor tcell.Color + if ui.UseColors { + textColor = tcell.NewRGBColor(0, 0, 0) + textBgColor = tcell.NewRGBColor(36, 121, 208) + } else { + textColor = tcell.NewRGBColor(0, 0, 0) + textBgColor = tcell.NewRGBColor(255, 255, 255) + } + + ui.grid.Clear() + + ui.statusMut.Lock() + defer ui.statusMut.Unlock() + + if show { + ui.status = tview.NewTextView().SetDynamicColors(true) + ui.status.SetTextColor(textColor) + ui.status.SetBackgroundColor(textBgColor) + + ui.grid.SetRows(1, 1, 0, 1, 1) + ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). + AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). + AddItem(ui.status, 3, 0, 1, 1, 0, 0, false). + AddItem(ui.footer, 4, 0, 1, 1, 0, 0, false) + return + } + ui.status = nil + ui.grid.SetRows(1, 1, 0, 1) + ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). + AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). + AddItem(ui.footer, 3, 0, 1, 1, 0, 0, false) +} + +func (ui *UI) updateStatusWorker() { + for { + ui.updateStatus() + time.Sleep(500 * time.Millisecond) + } +} + +func (ui *UI) updateStatus() { + ui.workersMut.Lock() + cnt := ui.activeWorkers + ui.workersMut.Unlock() + + ui.statusMut.RLock() + status := ui.status + ui.statusMut.RUnlock() + + if cnt == 0 && status == nil { + return + } + + if cnt > 0 && status == nil { + ui.app.QueueUpdateDraw(func() { + ui.toggleStatusBar(true) + }) + } else if cnt == 0 { + ui.app.QueueUpdateDraw(func() { + ui.toggleStatusBar(false) + }) + return + } + + ui.app.QueueUpdateDraw(func() { + msg := fmt.Sprintf(" Active background deletions: %d", cnt) + ui.statusMut.RLock() + ui.status.SetText(msg) + ui.statusMut.RUnlock() + }) +} diff --git a/tui/tui.go b/tui/tui.go new file mode 100644 index 0000000..32f3541 --- /dev/null +++ b/tui/tui.go @@ -0,0 +1,585 @@ +package tui + +import ( + "io" + "os" + "os/signal" + "runtime" + "sync" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/internal/common" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/dundee/gdu/v5/pkg/remove" + "github.com/dundee/gdu/v5/pkg/timefilter" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// UI struct +type UI struct { + app common.TermApplication + screen tcell.Screen + output io.Writer + currentDir fs.Item + topDir fs.Item + getter device.DevicesInfoGetter + *common.UI + grid *tview.Grid + header *tview.TextView + footer *tview.Flex + footerLabel *tview.TextView + currentDirLabel *tview.TextView + pages *tview.Pages + progress *tview.TextView + status *tview.TextView + help *tview.Flex + table *tview.Table + filteringInput *tview.InputField + typeFilteringInput *tview.InputField + done chan struct{} + remover func(fs.Item, fs.Item) error + emptier func(fs.Item, fs.Item) error + exec func(argv0 string, argv []string, envv []string) error + changeCwdFn func(string) error + linkedItems fs.HardLinkedItems + ignoredRows map[int]struct{} + markedRows map[int]struct{} + deleteQueue chan deleteQueueItem + resultRow ResultRow + topDirPath string + currentDirPath string + filterValue string + typeFilterValue string + sortBy string + sortOrder string + footerTextColor string + footerBackgroundColor string + footerNumberColor string + headerTextColor string + headerBackgroundColor string + defaultSortBy string + defaultSortOrder string + exportName string + devices []*device.Device + selectedTextColor tcell.Color + selectedBackgroundColor tcell.Color + currentItemNameMaxLen int + activeWorkers int + deleteWorkersCount int + statusMut sync.RWMutex + workersMut sync.Mutex + askBeforeDelete bool + showItemCount bool + showMtime bool + filtering bool + typeFiltering bool + headerHidden bool + useOldSizeBar bool + noDelete bool + noViewFile bool + noSpawnShell bool + deleteInBackground bool + timeFilter *timefilter.TimeFilter + timeFilterLoc *time.Location + noDeleteWithFilter bool + collapsePath bool + browseParentDirs bool +} + +type deleteQueueItem struct { + item fs.Item + shouldEmpty bool +} + +// ResultRow is a struct for a row in the result table +type ResultRow struct { + NumberColor string + DirectoryColor string +} + +// Option is optional function customizing the behaviour of UI +type Option func(ui *UI) + +// CreateUI creates the whole UI app +func CreateUI( + app common.TermApplication, + screen tcell.Screen, + output io.Writer, + useColors bool, + showApparentSize bool, + showRelativeSize bool, + useSIPrefix bool, + opts ...Option, +) *UI { + ui := &UI{ + UI: &common.UI{ + UseColors: useColors, + ShowApparentSize: showApparentSize, + ShowRelativeSize: showRelativeSize, + Analyzer: analyze.CreateAnalyzer(), + UseSIPrefix: useSIPrefix, + }, + app: app, + screen: screen, + output: output, + askBeforeDelete: true, + showItemCount: false, + remover: remove.ItemFromDir, + emptier: remove.EmptyFileFromDir, + exec: Execute, + linkedItems: make(fs.HardLinkedItems, 10), + selectedTextColor: tview.Styles.TitleColor, + selectedBackgroundColor: tview.Styles.MoreContrastBackgroundColor, + currentItemNameMaxLen: 70, + defaultSortBy: "size", + defaultSortOrder: "desc", + ignoredRows: make(map[int]struct{}), + markedRows: make(map[int]struct{}), + exportName: "export.json", + noDelete: false, + noViewFile: false, + noSpawnShell: false, + deleteQueue: make(chan deleteQueueItem, 1000), + deleteWorkersCount: 3 * runtime.GOMAXPROCS(0), + } + for _, o := range opts { + o(ui) + } + + ui.resetSorting() + + app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { + screen.Clear() + return false + }) + + ui.app.SetInputCapture(ui.keyPressed) + ui.app.SetMouseCapture(ui.onMouse) + + ui.header = tview.NewTextView() + ui.header.SetText(" gdu ~ Use arrow keys to navigate, press ? for help ") + ui.header.SetTextColor(tcell.GetColor(ui.headerTextColor)) + ui.header.SetBackgroundColor(tcell.GetColor(ui.headerBackgroundColor)) + + ui.currentDirLabel = tview.NewTextView() + ui.currentDirLabel.SetTextColor(tcell.ColorDefault) + ui.currentDirLabel.SetBackgroundColor(tcell.ColorDefault) + + ui.table = tview.NewTable().SetSelectable(true, false) + ui.table.SetBackgroundColor(tcell.ColorDefault) + ui.table.SetSelectedFunc(ui.fileItemSelected) + + if ui.UseColors { + ui.table.SetSelectedStyle(tcell.Style{}. + Foreground(ui.selectedTextColor). + Background(ui.selectedBackgroundColor).Bold(true)) + } else { + ui.table.SetSelectedStyle(tcell.Style{}. + Foreground(tcell.ColorWhite). + Background(tcell.ColorGray).Bold(true)) + } + + ui.footerLabel = tview.NewTextView().SetDynamicColors(true) + ui.footerLabel.SetTextColor(tcell.GetColor(ui.footerTextColor)) + ui.footerLabel.SetBackgroundColor(tcell.GetColor(ui.footerBackgroundColor)) + ui.footerLabel.SetText(" No items to display. ") + + ui.footer = tview.NewFlex() + ui.footer.AddItem(ui.footerLabel, 0, 1, false) + + ui.createGrid() + + ui.pages = tview.NewPages(). + AddPage("background", ui.grid, true, true) + ui.pages.SetBackgroundColor(tcell.ColorDefault) + + ui.app.SetRoot(ui.pages, true) + + return ui +} + +// createGrid creates the main grid layout +func (ui *UI) createGrid() { + if ui.headerHidden { + ui.grid = tview.NewGrid().SetRows(1, 0, 1).SetColumns(0) + ui.grid.AddItem(ui.currentDirLabel, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.table, 1, 0, 1, 1, 0, 0, true). + AddItem(ui.footer, 2, 0, 1, 1, 0, 0, false) + } else { + ui.grid = tview.NewGrid().SetRows(1, 1, 0, 1).SetColumns(0) + ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). + AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). + AddItem(ui.footer, 3, 0, 1, 1, 0, 0, false) + } +} + +// SetSelectedTextColor sets the color for the highlighted selected text +func (ui *UI) SetSelectedTextColor(color tcell.Color) { + ui.selectedTextColor = color +} + +// SetSelectedBackgroundColor sets the color for the highlighted selected text +func (ui *UI) SetSelectedBackgroundColor(color tcell.Color) { + ui.selectedBackgroundColor = color +} + +// SetFooterTextColor sets the color for the footer text +func (ui *UI) SetFooterTextColor(color string) { + ui.footerTextColor = color +} + +// SetFooterBackgroundColor sets the color for the footer background +func (ui *UI) SetFooterBackgroundColor(color string) { + ui.footerBackgroundColor = color +} + +// SetFooterNumberColor sets the color for the footer number +func (ui *UI) SetFooterNumberColor(color string) { + ui.footerNumberColor = color +} + +// SetHeaderTextColor sets the color for the header text +func (ui *UI) SetHeaderTextColor(color string) { + ui.headerTextColor = color +} + +// SetHeaderBackgroundColor sets the color for the header background +func (ui *UI) SetHeaderBackgroundColor(color string) { + ui.headerBackgroundColor = color +} + +// SetHeaderHidden sets the flag to hide the header +func (ui *UI) SetHeaderHidden() { + ui.headerHidden = true +} + +// SetResultRowDirectoryColor sets the color for the result row directory +func (ui *UI) SetResultRowDirectoryColor(color string) { + ui.resultRow.DirectoryColor = color +} + +// SetResultRowNumberColor sets the color for the result row number +func (ui *UI) SetResultRowNumberColor(color string) { + ui.resultRow.NumberColor = color +} + +// SetCurrentItemNameMaxLen sets the maximum length of the path of the currently processed item +// to be shown in the progress modal +func (ui *UI) SetCurrentItemNameMaxLen(maxLen int) { + ui.currentItemNameMaxLen = maxLen +} + +// UseOldSizeBar uses the old size bar (# chars) instead of the new one (unicode block elements) +func (ui *UI) UseOldSizeBar() { + ui.useOldSizeBar = true +} + +// SetChangeCwdFn sets function that can be used to change current working dir +// during dir browsing +func (ui *UI) SetChangeCwdFn(fn func(string) error) { + ui.changeCwdFn = fn +} + +// SetDeleteInParallel sets the flag to delete files in parallel +func (ui *UI) SetDeleteInParallel() { + ui.remover = remove.ItemFromDirParallel +} + +// StartUILoop starts tview application +func (ui *UI) StartUILoop() error { + go func() { + c := make(chan os.Signal, 1) + signal.Notify( + c, + syscall.SIGHUP, + syscall.SIGINT, + syscall.SIGQUIT, + syscall.SIGILL, + syscall.SIGTRAP, + syscall.SIGABRT, + syscall.SIGPIPE, + syscall.SIGTERM, + ) + s := <-c + log.Printf("Got signal: %s", s) + ui.app.QueueUpdateDraw(func() { + ui.app.Stop() + }) + }() + + return ui.app.Run() +} + +// SetShowItemCount sets the flag to show number of items in directory +func (ui *UI) SetShowItemCount() { + ui.showItemCount = true +} + +// SetShowMTime sets the flag to show last modification time of items in directory +func (ui *UI) SetShowMTime() { + ui.showMtime = true +} + +// SetNoDelete disables all write operations +func (ui *UI) SetNoDelete() { + ui.noDelete = true +} + +// SetNoSpawnShell disables shell spawning +func (ui *UI) SetNoSpawnShell() { + ui.noSpawnShell = true +} + +func (ui *UI) SetNoViewFile() { + ui.noViewFile = true +} + +// SetNoDelete disables delete when time filters are active +func (ui *UI) SetNoDeleteWithFilter() { + ui.noDeleteWithFilter = true +} + +// SetBrowseParentDirs enables navigating above the launch directory +func (ui *UI) SetBrowseParentDirs() { + ui.browseParentDirs = true +} + +// SetCollapsePath sets the flag to collapse paths +func (ui *UI) SetCollapsePath(value bool) { + ui.collapsePath = value +} + +// SetDeleteInBackground sets the flag to delete files in background +func (ui *UI) SetDeleteInBackground() { + ui.deleteInBackground = true + + for i := 0; i < ui.deleteWorkersCount; i++ { + go ui.deleteWorker() + } + go ui.updateStatusWorker() +} + +func (ui *UI) resetSorting() { + ui.sortBy = ui.defaultSortBy + ui.sortOrder = ui.defaultSortOrder +} + +func (ui *UI) rescanDir() { + ui.Analyzer.ResetProgress() + ui.linkedItems = make(fs.HardLinkedItems) + err := ui.AnalyzePath(ui.currentDirPath, ui.currentDir.GetParent()) + if err != nil { + ui.showErr("Error rescanning path", err) + } +} + +func (ui *UI) fileItemSelected(row, column int) { + if ui.currentDir == nil { + return // Add this check to handle nil case + } + + selectedDirCell := ui.table.GetCell(row, column) + + // Check if the selectedDirCell is nil before using it + if selectedDirCell == nil || selectedDirCell.GetReference() == nil { + return + } + + selectedDir := selectedDirCell.GetReference().(fs.Item) + if selectedDir == nil || !selectedDir.IsDir() { + return + } + + origDir := ui.currentDir + ui.currentDir = selectedDir + ui.hideFilterInput() + ui.hideTypeFilterInput() + ui.markedRows = make(map[int]struct{}) + ui.ignoredRows = make(map[int]struct{}) + ui.showDir() + + if row != 0 || origDir.GetPath() == ui.topDir.GetPath() { + return + } + + // we are going up in the directory tree, select the last visited directory + if origDir.GetParent() != nil { + nestedDir := origDir + for nestedDir.GetParent() != nil { + if selectedDir.GetName() == nestedDir.GetParent().GetName() { + sortBy, sortOrder := ui.getSortParams() + index := -1 + i := 0 + for item := range ui.currentDir.GetFiles(sortBy, sortOrder) { + if item.GetName() == nestedDir.GetName() { + index = i + break + } + i++ + } + if index >= 0 { + if ui.currentDir.GetPath() != ui.topDir.GetPath() { + index++ + } + ui.table.Select(index, 0) + } + break + } + nestedDir = nestedDir.GetParent() + } + } +} + +func (ui *UI) deviceItemSelected(row, column int) { + var err error + selectedDevice, ok := ui.table.GetCell(row, column).GetReference().(*device.Device) + if !ok { + return + } + + paths := device.GetNestedMountpointsPaths(selectedDevice.MountPoint, ui.devices) + ui.IgnoreDirPathPatterns, err = common.CreateIgnorePattern(paths) + if err != nil { + log.Printf("Creating path patterns for other devices failed: %s", paths) + } + + ui.resetSorting() + + ui.Analyzer.ResetProgress() + ui.linkedItems = make(fs.HardLinkedItems) + err = ui.AnalyzePath(selectedDevice.MountPoint, nil) + if err != nil { + ui.showErr("Error analyzing device", err) + } +} + +func (ui *UI) confirmDeletion(shouldEmpty bool) { + if ui.noDelete { + previousHeaderText := ui.header.GetText(false) + + // show feedback to user + ui.header.SetText(" Deletion is disabled!") + + go func() { + time.Sleep(2 * time.Second) + ui.app.QueueUpdateDraw(func() { + ui.header.Clear() + ui.header.SetText(previousHeaderText) + }) + }() + + return + } + + // Check if deletion is allowed with active time filters + if ui.noDeleteWithFilter { + modal := tview.NewModal(). + SetText("Deletion is disabled when a time filter is active.\n\n" + + "To override, set GDU_ALLOW_DELETE_WITH_FILTER=1"). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + ui.pages.RemovePage("confirm") + }) + if !ui.UseColors { + modal.SetBackgroundColor(tcell.ColorGray) + } + ui.pages.AddPage("confirm", modal, true, true) + return + } + + if len(ui.markedRows) > 0 { + ui.confirmDeletionMarked(shouldEmpty) + } else { + ui.confirmDeletionSelected(shouldEmpty) + } +} + +func (ui *UI) confirmDeletionSelected(shouldEmpty bool) { + row, column := ui.table.GetSelection() + selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) + var action string + if shouldEmpty { + action = "empty" + } else { + action = "delete" + } + modal := tview.NewModal(). + SetText( + "Are you sure you want to " + + action + + " \"" + + tview.Escape(selectedFile.GetName()) + + "\"?", + ). + AddButtons([]string{"no", "yes", "don't ask me again"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonIndex { + case 2: + ui.askBeforeDelete = false + fallthrough + case 1: + ui.deleteSelected(shouldEmpty) + } + ui.pages.RemovePage("confirm") + }) + + if !ui.UseColors { + modal.SetBackgroundColor(tcell.ColorGray) + } else { + modal.SetBackgroundColor(tcell.ColorBlack) + } + modal.SetBorderColor(tcell.ColorDefault) + + ui.pages.AddPage("confirm", modal, true, true) +} + +// SetTimeFilterWithInfo sets both the time filter function and stores the filter info for display +func (ui *UI) SetTimeFilterWithInfo(tf *timefilter.TimeFilter, loc *time.Location) { + ui.timeFilter = tf + ui.timeFilterLoc = loc + + if tf != nil && !tf.IsEmpty() { + timeFilterFunc := func(mtime time.Time) bool { + return tf.IncludeByTimeFilter(mtime, loc) + } + ui.SetTimeFilter(timeFilterFunc) + if !ui.isDeleteAllowedWithFilter() { + ui.SetNoDeleteWithFilter() + } + } +} + +// hasActiveTimeFilter returns true if any time filter is active +func (ui *UI) hasActiveTimeFilter() bool { + return ui.timeFilter != nil && !ui.timeFilter.IsEmpty() +} + +// formatTimeFilterInfo formats the time filter information for display +func (ui *UI) formatTimeFilterInfo() string { + if !ui.hasActiveTimeFilter() { + return "" + } + + return ui.timeFilter.FormatForDisplay(ui.timeFilterLoc) +} + +// isDeleteAllowedWithFilter checks if deletion is allowed when filters are active +func (ui *UI) isDeleteAllowedWithFilter() bool { + if !ui.hasActiveTimeFilter() { + return true + } + + // Check environment variable override + if os.Getenv("GDU_ALLOW_DELETE_WITH_FILTER") == "1" { + return true + } + + return false +} diff --git a/tui/tui_test.go b/tui/tui_test.go new file mode 100644 index 0000000..57239a9 --- /dev/null +++ b/tui/tui_test.go @@ -0,0 +1,1019 @@ +package tui + +import ( + "bytes" + "errors" + "fmt" + "os" + "testing" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/dundee/gdu/v5/internal/testanalyze" + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/internal/testdev" + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/device" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestFooter(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(15, 15) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "xxx", + Size: 5, + Usage: 4096, + }, + BasePath: ".", + ItemCount: 2, + } + + file := &analyze.File{ + Name: "yyy", + Size: 2, + Usage: 4096, + Parent: dir, + } + dir.Files = fs.Files{file} + + ui.currentDir = dir + ui.showDir() + ui.pages.HidePage("progress") + + ui.footerLabel.Draw(simScreen) + simScreen.Show() + + b, _, _ := simScreen.GetContents() + + // printScreen(simScreen) + + text := []byte(" Total disk usage: 4.0 KiB Apparent size: 2 B Items: 1") + for i, r := range b { + if i >= len(text) { + break + } + assert.Equal(t, string(text[i]), string(r.Bytes[0]), fmt.Sprintf("Index: %d", i)) + } +} + +func TestUpdateProgress(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) + done := ui.Analyzer.GetDone() + done.Broadcast() + ui.updateProgress() + assert.True(t, true) +} + +func TestHelp(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + ui.showHelp() + + assert.True(t, ui.pages.HasPage("help")) + + ui.help.Draw(simScreen) + simScreen.Show() + + // printScreen(simScreen) + + b, _, _ := simScreen.GetContents() + + cells := b[557 : 557+9] + + text := []byte("directory") + for i, r := range cells { + assert.Equal(t, text[i], r.Bytes[0]) + } +} + +func TestHelpBw(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.showHelp() + ui.help.Draw(simScreen) + simScreen.Show() + + // printScreen(simScreen) + + b, _, _ := simScreen.GetContents() + + cells := b[557 : 557+9] + + text := []byte("directory") + for i, r := range cells { + assert.Equal(t, text[i], r.Bytes[0]) + } +} + +func TestAppRun(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + err := ui.StartUILoop() + + assert.Nil(t, err) +} + +func TestAppRunWithErr(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + err := ui.StartUILoop() + + assert.Equal(t, "Fail", err.Error()) +} + +func TestRescanDir(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + ui.rescanDir() + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, parentDir, ui.currentDir.GetParent()) + + assert.Equal(t, 5, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") +} + +func TestDirSelected(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, false, false) + ui.done = make(chan struct{}) + + ui.fileItemSelected(0, 0) + + assert.Equal(t, 3, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") + assert.Contains(t, ui.table.GetCell(1, 0).Text, "subnested") +} + +func TestFileSelected(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + ui.fileItemSelected(3, 0) + + assert.Equal(t, 4, ui.table.GetRowCount()) + assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") +} + +func TestSelectedWithoutCurrentDir(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + ui.fileItemSelected(1, 0) + + assert.Nil(t, ui.currentDir) +} + +func TestBeforeDraw(t *testing.T) { + screen := tcell.NewSimulationScreen("UTF-8") + err := screen.Init() + + assert.Nil(t, err) + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, screen, &bytes.Buffer{}, false, true, false, false) + + for _, f := range ui.app.(*testapp.MockedApp).BeforeDraws { + assert.False(t, f(screen)) + } +} + +func TestIgnorePaths(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + ui.SetIgnoreDirPaths([]string{"/aaa", "/bbb"}) + + assert.True(t, ui.ShouldDirBeIgnored("aaa", "/aaa")) + assert.True(t, ui.ShouldDirBeIgnored("bbb", "/bbb")) + assert.False(t, ui.ShouldDirBeIgnored("ccc", "/ccc")) +} + +func TestConfirmDeletion(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + ui.table.Select(1, 0) + ui.confirmDeletion(false) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionBW(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.confirmDeletion(false) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmEmpty(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.confirmDeletion(true) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmEmptyMarked(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletion(true) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionMarked(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletion(false) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionMarkedBW(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletion(false) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestDeleteSelected(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInParallel() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.remover = testanalyze.ItemFromDirWithSleep + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInBackgroundAndParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.remover = testanalyze.ItemFromDirWithSleep + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + ui.SetDeleteInParallel() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInBackgroundBW(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestEmptyDirInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(true) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") +} + +func TestEmptyFileInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.fileItemSelected(0, 0) // nested + ui.table.Select(2, 0) + + ui.deleteSelected(true) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.FileExists(t, "test_dir/nested/file2") + + f, err := os.Open("test_dir/nested/file2") + assert.Nil(t, err) + info, err := f.Stat() + assert.Nil(t, err) + assert.Equal(t, int64(0), info.Size()) +} + +func TestDeleteSelectedWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.remover = testanalyze.ItemFromDirWithErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.delete(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) + assert.DirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInBackgroundWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetDeleteInBackground() + ui.remover = testanalyze.ItemFromDirWithSleepAndErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.delete(false) + + <-ui.done + + // change the status + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + // wait for status to be removed + time.Sleep(500 * time.Millisecond) + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) + assert.DirExists(t, "test_dir/nested") +} + +func TestDeleteMarkedWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.remover = testanalyze.ItemFromDirWithErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + ui.markedRows[0] = struct{}{} + + ui.deleteMarked(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) + assert.DirExists(t, "test_dir/nested") +} + +func TestDeleteMarkedInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.fileItemSelected(0, 0) // nested + + ui.markedRows[1] = struct{}{} // subnested + ui.markedRows[2] = struct{}{} // file2 + + ui.deleteMarked(false) + + <-ui.done // wait for deletion of subnested + <-ui.done // wait for deletion of file2 + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") + assert.NoFileExists(t, "test_dir/nested/file2") +} + +func TestDeleteMarkedInBackgroundWithStorage(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetAnalyzer(analyze.CreateStoredAnalyzer("/tmp/badger")) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.fileItemSelected(0, 0) // nested + + ui.markedRows[1] = struct{}{} // subnested + ui.markedRows[2] = struct{}{} // file2 + + ui.deleteMarked(false) + + <-ui.done // wait for deletion of subnested + <-ui.done // wait for deletion of file2 + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") + assert.NoFileExists(t, "test_dir/nested/file2") +} + +func TestDeleteMarkedInBackgroundWithStorageAndParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetAnalyzer(analyze.CreateStoredAnalyzer("/tmp/badger")) + ui.SetDeleteInBackground() + ui.SetDeleteInParallel() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.fileItemSelected(0, 0) // nested + + ui.markedRows[1] = struct{}{} // subnested + ui.markedRows[2] = struct{}{} // file2 + + ui.deleteMarked(false) + + <-ui.done // wait for deletion of subnested + <-ui.done // wait for deletion of file2 + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") + assert.NoFileExists(t, "test_dir/nested/file2") +} + +func TestDeleteMarkedInBackgroundWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetDeleteInBackground() + ui.remover = testanalyze.ItemFromDirWithErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + ui.markedRows[0] = struct{}{} + + ui.deleteMarked(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) + assert.DirExists(t, "test_dir/nested") +} + +func TestShowErr(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + ui.showErr("Something went wrong", errors.New("error")) + + assert.True(t, ui.pages.HasPage("error")) +} + +func TestShowErrBW(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + ui.showErr("Something went wrong", errors.New("error")) + + assert.True(t, ui.pages.HasPage("error")) +} + +func TestMin(t *testing.T) { + assert.Equal(t, 2, min(2, 5)) + assert.Equal(t, 3, min(4, 3)) +} + +func TestSetStyles(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + opts := []Option{} + opts = append(opts, func(ui *UI) { + ui.SetHeaderHidden() + }) + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, opts...) + + ui.SetSelectedBackgroundColor(tcell.ColorRed) + ui.SetSelectedTextColor(tcell.ColorRed) + ui.SetFooterTextColor("red") + ui.SetFooterBackgroundColor("red") + ui.SetFooterNumberColor("red") + ui.SetHeaderTextColor("red") + ui.SetHeaderBackgroundColor("red") + ui.SetResultRowDirectoryColor("red") + ui.SetResultRowNumberColor("red") + + assert.Equal(t, ui.selectedBackgroundColor, tcell.ColorRed) + assert.Equal(t, ui.selectedTextColor, tcell.ColorRed) + assert.Equal(t, ui.footerTextColor, "red") + assert.Equal(t, ui.footerBackgroundColor, "red") + assert.Equal(t, ui.footerNumberColor, "red") + assert.Equal(t, ui.headerTextColor, "red") + assert.Equal(t, ui.headerBackgroundColor, "red") + assert.Equal(t, ui.headerHidden, true) + assert.Equal(t, ui.resultRow.DirectoryColor, "red") + assert.Equal(t, ui.resultRow.NumberColor, "red") +} + +func TestSetCurrentItemNameMaxLen(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + ui.SetCurrentItemNameMaxLen(5) + + assert.Equal(t, ui.currentItemNameMaxLen, 5) +} + +func TestUseOldSizeBar(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + ui.UseOldSizeBar() + + assert.Equal(t, ui.useOldSizeBar, true) +} + +func TestSetShowItemCount(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + ui.SetShowItemCount() + + assert.Equal(t, ui.showItemCount, true) +} + +func TestSetShowMTime(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + ui.SetShowMTime() + + assert.Equal(t, ui.showMtime, true) +} + +func TestNoDelete(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + ui.SetNoDelete() + + assert.Equal(t, ui.noDelete, true) +} + +func TestNoSpawnShell(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + ui.SetNoSpawnShell() + + assert.Equal(t, ui.noSpawnShell, true) +} + +func TestNoViewFile(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + + ui.SetNoViewFile() + + assert.Equal(t, ui.noViewFile, true) +} + +// nolint: unused // Why: for debugging +func printScreen(simScreen tcell.SimulationScreen) { + b, _, _ := simScreen.GetContents() + + for i, r := range b { + if string(r.Bytes) != " " { + println(i, string(r.Bytes)) + } + } +} + +func getDevicesInfoMock() device.DevicesInfoGetter { + item := &device.Device{ + Name: "/dev/root", + MountPoint: "test_dir", + Size: 1e12, + Free: 1e6, + } + item2 := &device.Device{ + Name: "/dev/boot", + MountPoint: "/boot", + Size: 1e6, + Free: 1e3, + } + + mock := testdev.DevicesInfoGetterMock{} + mock.Devices = []*device.Device{item, item2} + return mock +} + +func getAnalyzedPathMockedApp(t *testing.T, useColors, apparentSize, mockedAnalyzer bool) *UI { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, useColors, apparentSize, false, false) + + if mockedAnalyzer { + ui.Analyzer = &testanalyze.MockedAnalyzer{} + } + ui.done = make(chan struct{}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + return ui +} + +func TestConfirmDeletionSelectedButtonOrder(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + ui.table.Select(1, 0) + ui.confirmDeletionSelected(false) + + // Verify confirmation page is created + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionSelectedSafeDefault(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + + assert.Equal(t, 1, ui.table.GetRowCount()) + ui.table.Select(0, 0) + + // Create confirmation dialog + ui.confirmDeletionSelected(false) + + // Verify that the confirmation dialog exists with safer defaults + assert.DirExists(t, "test_dir/nested") + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionButtonIndexMapping(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false // Skip confirmation for direct testing + + assert.Equal(t, 1, ui.table.GetRowCount()) + ui.table.Select(0, 0) + + // Test that deletion still works when explicitly called + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestConfirmEmptySelectedSafeDefault(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + ui.table.Select(1, 0) + ui.confirmDeletionSelected(true) + + // Verify empty confirmation dialog is created safely + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionMarkedSafeDefault(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletionMarked(false) + + // Verify marked deletion confirmation dialog is created safely + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmEmptyMarkedSafeDefault(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletionMarked(true) + + // Verify marked empty confirmation dialog is created safely + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestSaferConfirmationPreventDataLoss(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + + assert.Equal(t, 1, ui.table.GetRowCount()) + ui.table.Select(0, 0) + + // Test that creating confirmation dialog doesn't accidentally trigger deletion + ui.confirmDeletionSelected(false) + ui.confirmDeletionSelected(true) // empty + + // Directory should still exist - no accidental deletion + assert.DirExists(t, "test_dir/nested") + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionSelectedCase1(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + + assert.Equal(t, 1, ui.table.GetRowCount()) + ui.table.Select(0, 0) + + // Test case 1 branch (yes button at index 1) by directly calling deleteSelected + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestConfirmDeletionMarkedCase1(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + + ui.fileItemSelected(0, 0) // nested + ui.markedRows[1] = struct{}{} // subnested + + // Test case 1 branch (yes button at index 1) by directly calling deleteMarked + ui.deleteMarked(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested/subnested") +} diff --git a/tui/utils.go b/tui/utils.go new file mode 100644 index 0000000..4b3b77b --- /dev/null +++ b/tui/utils.go @@ -0,0 +1,185 @@ +package tui + +import ( + "path/filepath" + "slices" + + "github.com/dundee/gdu/v5/pkg/device" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/rivo/tview" +) + +var ( + barFullRune = "\u2588" + barPartRunes = map[int]string{ + 0: " ", + 1: "\u258F", + 2: "\u258E", + 3: "\u258D", + 4: "\u258C", + 5: "\u258B", + 6: "\u258A", + 7: "\u2589", + } +) + +func getDeviceUsagePart(item *device.Device, useOld bool) string { + part := int(float64(item.Size-item.Free) / float64(item.Size) * 100.0) + if useOld { + return getUsageGraphOld(part) + } + return getUsageGraph(part) +} + +func getUsageGraph(part int) string { + graph := " " + whole := part / 10 + for i := 0; i < whole; i++ { + graph += barFullRune + } + partWidth := (part % 10) * 8 / 10 + if part < 100 { + graph += barPartRunes[partWidth] + } + + for i := 0; i < 10-whole-1; i++ { + graph += " " + } + + graph += "\u258F" + return graph +} + +func getUsageGraphOld(part int) string { + part /= 10 + graph := "[" + for i := 0; i < 10; i++ { + if part > i { + graph += "#" + } else { + graph += " " + } + } + graph += "]" + return graph +} + +func modal(p tview.Primitive, width, height int) tview.Primitive { + return tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(p, height, 1, true). + AddItem(nil, 0, 1, false), width, 1, true). + AddItem(nil, 0, 1, false) +} + +// CollapsedPath represents a directory chain that can be collapsed into a single display entry. +// For example, if directory "a" contains only directory "b", and "b" contains only "c", +// this represents the collapsed path "a/b/c" that allows direct navigation to the deepest directory. +type CollapsedPath struct { + DisplayName string // The display name shown in the UI (e.g., "a/b/c") + DeepestDir fs.Item // The actual deepest directory item + Segments []string // Individual path segments of the collapsed chain +} + +// findCollapsiblePath checks if the given directory item has a single subdirectory chain +// and returns a CollapsedPath if it can be collapsed +func findCollapsiblePath(item fs.Item) *CollapsedPath { + if item == nil || !item.IsDir() { + return nil + } + + var segments []string + current := item + + for { + // Collect files to check count and types + var files []fs.Item + for file := range current.GetFiles(fs.SortByName, fs.SortAsc) { + files = append(files, file) + } + + if len(files) > 1 { + break + } + + // Count directories and files separately + var subdirs []fs.Item + var fileCount int + for _, file := range files { + if file.IsDir() { + subdirs = append(subdirs, file) + } else { + fileCount++ + } + } + + // Only collapse if there's exactly one subdirectory AND no files + if len(subdirs) != 1 || fileCount > 0 { + break + } + + // Add this segment to the path + // nolint:staticcheck // the result is used + segments = append(segments, subdirs[0].GetName()) + current = subdirs[0] + } + + // Only create collapsed path if we have at least one collapsible segment + if len(segments) == 0 { + return nil + } + + return &CollapsedPath{ + DisplayName: filepath.Join(slices.Concat([]string{item.GetName()}, segments)...), + DeepestDir: current, + Segments: segments, + } +} + +// findCollapsedParent checks if the current directory is the deepest directory +// in a collapsed path, and returns the appropriate parent to navigate to +func findCollapsedParent(currentDir fs.Item) fs.Item { + if currentDir == nil { + return nil + } + if currentDir.GetParent() == nil { + return nil + } + + // Check if current directory is part of a single-child chain going up + current := currentDir + var chainParent fs.Item + + // Walk up the parent chain + for current.GetParent() != nil { + parent := current.GetParent() + + // Count files in parent + fileCount := 0 + for range parent.GetFiles(fs.SortByName, fs.SortAsc) { + fileCount++ + if fileCount > 1 { + break + } + } + + // If parent has more than one item, this is where the collapsed chain starts + if fileCount > 1 { + chainParent = parent + break + } + + // Move up the chain + current = parent + } + + // If we found a chain parent (meaning current dir is part of a collapsed path), + // return it, otherwise return the normal parent + if chainParent != nil { + return chainParent + } + + return currentDir.GetParent() +} diff --git a/tui/utils_test.go b/tui/utils_test.go new file mode 100644 index 0000000..3ed960f --- /dev/null +++ b/tui/utils_test.go @@ -0,0 +1,31 @@ +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetUsageGraph(t *testing.T) { + assert.Equal(t, " \u258F", getUsageGraph(0)) + assert.Equal(t, " █ \u258F", getUsageGraph(10)) + assert.Equal(t, " ██ \u258F", getUsageGraph(20)) + assert.Equal(t, " ███ \u258F", getUsageGraph(30)) + assert.Equal(t, " ████ \u258F", getUsageGraph(40)) + assert.Equal(t, " █████ \u258F", getUsageGraph(50)) + assert.Equal(t, " ██████ \u258F", getUsageGraph(60)) + assert.Equal(t, " ███████ \u258F", getUsageGraph(70)) + assert.Equal(t, " ████████ \u258F", getUsageGraph(80)) + assert.Equal(t, " █████████ \u258F", getUsageGraph(90)) + assert.Equal(t, " ██████████\u258F", getUsageGraph(100)) + + assert.Equal(t, " █ \u258F", getUsageGraph(11)) + assert.Equal(t, " █▏ \u258F", getUsageGraph(12)) + assert.Equal(t, " █▎ \u258F", getUsageGraph(13)) + assert.Equal(t, " █▍ \u258F", getUsageGraph(14)) + assert.Equal(t, " █▌ \u258F", getUsageGraph(15)) + assert.Equal(t, " █▌ \u258F", getUsageGraph(16)) + assert.Equal(t, " █▋ \u258F", getUsageGraph(17)) + assert.Equal(t, " █▊ \u258F", getUsageGraph(18)) + assert.Equal(t, " █▉ \u258F", getUsageGraph(19)) +} -- 2.30.2